mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
chore: improve development environment seeding (#7162)
Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
This commit is contained in:
parent
8ab8421ea5
commit
2f1051eed0
16 changed files with 615 additions and 482 deletions
|
|
@ -111,19 +111,19 @@ We have a script to feed your local instance of Hive with initial seed data. Thi
|
|||
1. Use `Start Hive` to run your local Hive instance
|
||||
2. Make sure `usage` and `usage-ingestor` are running as well (with `pnpm dev`)
|
||||
3. Open Hive app, create a project and a target, then create a token
|
||||
4. Run the seed script: `TOKEN="MY_TOKEN_HERE" pnpm seed`
|
||||
5. This should report a dummy schema and some dummy usage data to your local instance of Hive,
|
||||
allowing you to test features e2e
|
||||
4. Run the seed script: `FEDERATION=<0|1> TOKEN=<access_token> TARGET=<target_id> pnpm seed:schemas`
|
||||
5. This should report a dummy schema
|
||||
6. Run the usage seed to generate some dummy usage data to your local instance of Hive, allowing you
|
||||
to test features e2e: `FEDERATION=<0|1> TOKEN=<access_token> TARGET=<target_id> pnpm seed:usage`
|
||||
|
||||
> Note: You can set `STAGING=1` in order to target staging env and seed a target there. Same for
|
||||
> development env, you can use `DEV=1`
|
||||
> Note: You can set `STAGE=<dev|staging|local>` in order to target a specific Hive environment and
|
||||
> seed a target there.
|
||||
|
||||
> Note: You can set `FEDERATION=1` in order to publish multiple subgraphs.
|
||||
|
||||
> To send more operations and test heavy load on Hive instance, you can also set `OPERATIONS`
|
||||
> (amount of operations in each interval round, default is `1`) and `INTERVAL` (frequency of sending
|
||||
> operations, default: `1000`ms). For example, using `INTERVAL=1000 OPERATIONS=1000` will send 1000
|
||||
> requests per second.
|
||||
> To send more operations with `seed:usage`, and test heavy load on Hive instance, you can also set
|
||||
> `OPERATIONS` (amount of operations in each interval round, default is `10`) and `INTERVAL`
|
||||
> (frequency of sending operations, default: `1000`ms). For example, using
|
||||
> `INTERVAL=1000 OPERATIONS=1000` will send 1000 requests per second. And set `BATCHES` to set the
|
||||
> total number of batches to run before the seed exits. Default: 10.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
"prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"",
|
||||
"release": "pnpm build:libraries && changeset publish",
|
||||
"release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
|
||||
"seed": "tsx scripts/seed-local-env.ts",
|
||||
"seed:schemas": "tsx scripts/seed-schemas.ts",
|
||||
"seed:usage": "tsx scripts/seed-usage.ts",
|
||||
"start": "pnpm run local:setup",
|
||||
"test": "vitest",
|
||||
"test:e2e": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress run --browser chrome",
|
||||
|
|
@ -69,6 +70,7 @@
|
|||
"@graphql-codegen/urql-introspection": "3.0.1",
|
||||
"@graphql-eslint/eslint-plugin": "3.20.1",
|
||||
"@graphql-inspector/cli": "4.0.3",
|
||||
"@graphql-tools/load": "8.1.2",
|
||||
"@manypkg/get-packages": "2.2.2",
|
||||
"@next/eslint-plugin-next": "14.2.23",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ importers:
|
|||
'@graphql-inspector/cli':
|
||||
specifier: 4.0.3
|
||||
version: 4.0.3(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)
|
||||
'@graphql-tools/load':
|
||||
specifier: 8.1.2
|
||||
version: 8.1.2(graphql@16.9.0)
|
||||
'@manypkg/get-packages':
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
|
|
@ -1424,7 +1427,7 @@ importers:
|
|||
devDependencies:
|
||||
'@graphql-inspector/core':
|
||||
specifier: 5.1.0-alpha-20231208113249-34700c8a
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0)
|
||||
version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.11.0)
|
||||
'@hive/service-common':
|
||||
specifier: workspace:*
|
||||
version: link:../service-common
|
||||
|
|
@ -3663,6 +3666,7 @@ packages:
|
|||
|
||||
'@fastify/vite@6.0.7':
|
||||
resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==}
|
||||
bundledDependencies: []
|
||||
|
||||
'@floating-ui/core@1.2.6':
|
||||
resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}
|
||||
|
|
@ -17594,8 +17598,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 3.0.0
|
||||
'@aws-crypto/sha256-js': 3.0.0
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0
|
||||
'@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/client-sts': 3.596.0
|
||||
'@aws-sdk/core': 3.592.0
|
||||
'@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/middleware-host-header': 3.577.0
|
||||
|
|
@ -17702,11 +17706,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-sso-oidc@3.596.0':
|
||||
'@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 3.0.0
|
||||
'@aws-crypto/sha256-js': 3.0.0
|
||||
'@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)
|
||||
'@aws-sdk/client-sts': 3.596.0
|
||||
'@aws-sdk/core': 3.592.0
|
||||
'@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/middleware-host-header': 3.577.0
|
||||
|
|
@ -17745,6 +17749,7 @@ snapshots:
|
|||
'@smithy/util-utf8': 3.0.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/client-sts'
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)':
|
||||
|
|
@ -17878,11 +17883,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)':
|
||||
'@aws-sdk/client-sts@3.596.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 3.0.0
|
||||
'@aws-crypto/sha256-js': 3.0.0
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/core': 3.592.0
|
||||
'@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/middleware-host-header': 3.577.0
|
||||
|
|
@ -17921,7 +17926,6 @@ snapshots:
|
|||
'@smithy/util-utf8': 3.0.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/client-sso-oidc'
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-sts@3.723.0':
|
||||
|
|
@ -18035,7 +18039,7 @@ snapshots:
|
|||
|
||||
'@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)
|
||||
'@aws-sdk/client-sts': 3.596.0
|
||||
'@aws-sdk/credential-provider-env': 3.587.0
|
||||
'@aws-sdk/credential-provider-http': 3.596.0
|
||||
'@aws-sdk/credential-provider-process': 3.587.0
|
||||
|
|
@ -18154,7 +18158,7 @@ snapshots:
|
|||
|
||||
'@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)
|
||||
'@aws-sdk/client-sts': 3.596.0
|
||||
'@aws-sdk/types': 3.577.0
|
||||
'@smithy/property-provider': 3.1.11
|
||||
'@smithy/types': 3.7.2
|
||||
|
|
@ -18329,7 +18333,7 @@ snapshots:
|
|||
|
||||
'@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0
|
||||
'@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0)
|
||||
'@aws-sdk/types': 3.577.0
|
||||
'@smithy/property-provider': 3.1.11
|
||||
'@smithy/shared-ini-file-loader': 3.1.12
|
||||
|
|
@ -19961,6 +19965,13 @@ snapshots:
|
|||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
|
||||
'@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.11.0)':
|
||||
dependencies:
|
||||
dependency-graph: 0.11.0
|
||||
graphql: 16.11.0
|
||||
object-inspect: 1.12.3
|
||||
tslib: 2.6.2
|
||||
|
||||
'@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0)':
|
||||
dependencies:
|
||||
dependency-graph: 0.11.0
|
||||
|
|
|
|||
|
|
@ -1,459 +0,0 @@
|
|||
import { buildSchema, parse } from 'graphql';
|
||||
import { createHive } from '@graphql-hive/core';
|
||||
|
||||
const isFederation = process.env.FEDERATION === '1';
|
||||
const isSchemaReportingEnabled = process.env.SCHEMA_REPORTING !== '0';
|
||||
const isUsageReportingEnabled = process.env.USAGE_REPORTING !== '0';
|
||||
|
||||
const envName = process.env.STAGING ? 'staging' : process.env.DEV ? 'dev' : 'local';
|
||||
|
||||
const schemaReportingEndpoint =
|
||||
envName === 'staging'
|
||||
? 'https://app.staging.graphql-hive.com/registry'
|
||||
: envName === 'dev'
|
||||
? 'https://app.dev.graphql-hive.com/registry'
|
||||
: 'http://localhost:3001/graphql';
|
||||
|
||||
const usageReportingEndpoint =
|
||||
envName === 'staging'
|
||||
? 'https://app.staging.graphql-hive.com/usage'
|
||||
: envName === 'dev'
|
||||
? 'https://app.dev.graphql-hive.com/usage'
|
||||
: 'http://localhost:4001';
|
||||
const target = process.env.TARGET;
|
||||
|
||||
console.log(`
|
||||
Environment: ${envName}
|
||||
Schema reporting endpoint: ${schemaReportingEndpoint}
|
||||
Usage reporting endpoint: ${usageReportingEndpoint}
|
||||
|
||||
Schema reporting: ${isSchemaReportingEnabled ? 'enabled' : 'disabled'}
|
||||
Usage reporting: ${isUsageReportingEnabled ? 'enabled' : 'disabled'}
|
||||
`);
|
||||
|
||||
const createInstance = (
|
||||
service: null | {
|
||||
name: string;
|
||||
url: string;
|
||||
},
|
||||
) => {
|
||||
return createHive({
|
||||
token: process.env.TOKEN!,
|
||||
agent: {
|
||||
name: 'Hive Seed Script',
|
||||
maxSize: 10,
|
||||
},
|
||||
debug: true,
|
||||
enabled: true,
|
||||
reporting: isSchemaReportingEnabled
|
||||
? {
|
||||
endpoint: schemaReportingEndpoint,
|
||||
author: 'Hive Seed Script',
|
||||
commit: '1',
|
||||
serviceName: service?.name,
|
||||
serviceUrl: service?.url,
|
||||
}
|
||||
: false,
|
||||
usage: isUsageReportingEnabled
|
||||
? {
|
||||
target: target || undefined,
|
||||
clientInfo: () => ({
|
||||
name: 'Fake Hive Client',
|
||||
version: '1.1.1',
|
||||
}),
|
||||
endpoint: usageReportingEndpoint,
|
||||
max: 10,
|
||||
sampleRate: 1,
|
||||
}
|
||||
: false,
|
||||
});
|
||||
};
|
||||
|
||||
async function single() {
|
||||
const hiveInstance = createInstance(null);
|
||||
|
||||
await hiveInstance.info();
|
||||
|
||||
const schema = buildSchema(/* GraphQL */ `
|
||||
type Query {
|
||||
field(arg: String): String
|
||||
nested: NestedQuery!
|
||||
}
|
||||
|
||||
type NestedQuery {
|
||||
test: String
|
||||
}
|
||||
`);
|
||||
|
||||
const query1 = parse(/* GraphQL */ `
|
||||
query test {
|
||||
field
|
||||
withArg: field(arg: "test")
|
||||
nested {
|
||||
test
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const query2 = parse(/* GraphQL */ `
|
||||
query testAnother {
|
||||
field
|
||||
}
|
||||
`);
|
||||
|
||||
hiveInstance.reportSchema({ schema });
|
||||
|
||||
const operationsPerBatch = process.env.OPERATIONS ? parseInt(process.env.OPERATIONS) : 1;
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
for (let i = 0; i < operationsPerBatch; i++) {
|
||||
const randNumber = Math.random() * 100;
|
||||
console.log('Reporting usage query...');
|
||||
|
||||
const done = hiveInstance.collectUsage();
|
||||
|
||||
done(
|
||||
{
|
||||
document: randNumber > 50 ? query1 : query2,
|
||||
schema,
|
||||
variableValues: {},
|
||||
contextValue: {},
|
||||
},
|
||||
randNumber > 90
|
||||
? {
|
||||
errors: undefined,
|
||||
}
|
||||
: {
|
||||
errors: [{ message: 'oops' }],
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
process.env.INTERVAL ? parseInt(process.env.INTERVAL) : 1000,
|
||||
);
|
||||
}
|
||||
|
||||
const publishMutationDocument =
|
||||
/* GraphQL */
|
||||
`
|
||||
mutation schemaPublish($input: SchemaPublishInput!) {
|
||||
schemaPublish(input: $input) {
|
||||
__typename
|
||||
... on SchemaPublishSuccess {
|
||||
initial
|
||||
valid
|
||||
message
|
||||
linkToWebsite
|
||||
changes {
|
||||
nodes {
|
||||
message
|
||||
criticality
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
... on SchemaPublishError {
|
||||
valid
|
||||
linkToWebsite
|
||||
changes {
|
||||
nodes {
|
||||
message
|
||||
criticality
|
||||
}
|
||||
total
|
||||
}
|
||||
errors {
|
||||
nodes {
|
||||
message
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function federation() {
|
||||
const instance = createInstance(null);
|
||||
const schemaInventory = /* GraphQL */ `
|
||||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@shareable", "@external", "@requires"]
|
||||
)
|
||||
type Product implements ProductItf @key(fields: "id") {
|
||||
id: ID!
|
||||
dimensions: ProductDimension @external
|
||||
delivery(zip: String): DeliveryEstimates @requires(fields: "dimensions { size weight }")
|
||||
}
|
||||
|
||||
type ProductDimension @shareable {
|
||||
size: String
|
||||
weight: Float
|
||||
}
|
||||
|
||||
type DeliveryEstimates {
|
||||
estimatedDelivery: String
|
||||
fastestDelivery: String
|
||||
}
|
||||
|
||||
interface ProductItf {
|
||||
id: ID!
|
||||
dimensions: ProductDimension
|
||||
delivery(zip: String): DeliveryEstimates
|
||||
}
|
||||
|
||||
enum ShippingClass {
|
||||
STANDARD
|
||||
EXPRESS
|
||||
OVERNIGHT
|
||||
}
|
||||
`;
|
||||
|
||||
const schemaPandas = /* GraphQL */ `
|
||||
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@tag"])
|
||||
directive @tag(name: String!) repeatable on FIELD_DEFINITION
|
||||
|
||||
type Query {
|
||||
allPandas: [Panda]
|
||||
panda(name: ID!): Panda
|
||||
}
|
||||
|
||||
type Panda {
|
||||
name: ID!
|
||||
favoriteFood: String @tag(name: "nom-nom-nom")
|
||||
}
|
||||
`;
|
||||
|
||||
const schemaProducts = /* GraphQL */ `
|
||||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@shareable", "@inaccessible", "@tag"]
|
||||
)
|
||||
directive @myDirective(a: String!) on FIELD_DEFINITION
|
||||
directive @hello on FIELD_DEFINITION
|
||||
|
||||
type Query {
|
||||
allProducts: [ProductItf]
|
||||
product(id: ID!): ProductItf
|
||||
}
|
||||
|
||||
interface ProductItf implements SkuItf {
|
||||
id: ID!
|
||||
sku: String
|
||||
name: String
|
||||
package: String
|
||||
variation: ProductVariation
|
||||
dimensions: ProductDimension
|
||||
createdBy: User
|
||||
hidden: String @inaccessible
|
||||
oldField: String @deprecated(reason: "refactored out")
|
||||
}
|
||||
|
||||
interface SkuItf {
|
||||
sku: String
|
||||
}
|
||||
|
||||
type Product implements ProductItf & SkuItf
|
||||
@key(fields: "id")
|
||||
@key(fields: "sku package")
|
||||
@key(fields: "sku variation { id }") {
|
||||
id: ID! @tag(name: "hi-from-products")
|
||||
sku: String
|
||||
name: String @hello
|
||||
package: String
|
||||
variation: ProductVariation
|
||||
dimensions: ProductDimension
|
||||
createdBy: User
|
||||
hidden: String
|
||||
reviewsScore: Float!
|
||||
oldField: String
|
||||
}
|
||||
enum ShippingClass {
|
||||
STANDARD
|
||||
EXPRESS
|
||||
}
|
||||
type ProductVariation {
|
||||
id: ID!
|
||||
name: String
|
||||
}
|
||||
type ProductDimension @shareable {
|
||||
size: String
|
||||
weight: Float
|
||||
}
|
||||
type User @key(fields: "email") {
|
||||
email: ID!
|
||||
totalProductsCreated: Int @shareable
|
||||
}
|
||||
`;
|
||||
|
||||
const schemaReviews = /* GraphQL */ `
|
||||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@shareable", "@override"]
|
||||
)
|
||||
|
||||
type Product implements ProductItf @key(fields: "id") {
|
||||
id: ID!
|
||||
reviewsCount: Int!
|
||||
reviewsScore: Float! @shareable @override(from: "products")
|
||||
reviews: [Review!]!
|
||||
}
|
||||
|
||||
interface ProductItf {
|
||||
id: ID!
|
||||
reviewsCount: Int!
|
||||
reviewsScore: Float!
|
||||
reviews: [Review!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
review(id: Int!): Review
|
||||
}
|
||||
|
||||
type Review {
|
||||
id: Int!
|
||||
body: String!
|
||||
}
|
||||
`;
|
||||
|
||||
const schemaUsers = /* GraphQL */ `
|
||||
extend schema
|
||||
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@tag", "@shareable"])
|
||||
directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT
|
||||
|
||||
type User @key(fields: "email") {
|
||||
email: ID! @tag(name: "test-from-users")
|
||||
name: String
|
||||
totalProductsCreated: Int @shareable
|
||||
createdAt: DateTime
|
||||
}
|
||||
|
||||
scalar DateTime
|
||||
`;
|
||||
|
||||
let res = await fetch(schemaReportingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: schemaInventory,
|
||||
service: 'Inventory',
|
||||
url: 'https://inventory.localhost/graphql',
|
||||
target: target ? { byId: target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
res = await fetch(schemaReportingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: schemaPandas,
|
||||
service: 'Panda',
|
||||
url: 'https://panda.localhost/graphql',
|
||||
target: target ? { byId: target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
res = await fetch(schemaReportingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: schemaProducts,
|
||||
service: 'Products',
|
||||
url: 'https://products.localhost/graphql',
|
||||
target: target ? { byId: target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
res = await fetch(schemaReportingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: schemaReviews,
|
||||
service: 'Reviews',
|
||||
url: 'https://reviews.localhost/graphql',
|
||||
target: target ? { byId: target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
res = await fetch(schemaReportingEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: schemaUsers,
|
||||
service: 'Users',
|
||||
url: 'https://users.localhost/graphql',
|
||||
target: target ? { byId: target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
|
||||
await instance.info();
|
||||
}
|
||||
|
||||
if (isFederation === false) {
|
||||
await single();
|
||||
} else {
|
||||
await federation();
|
||||
}
|
||||
168
scripts/seed-schemas.ts
Normal file
168
scripts/seed-schemas.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Example:
|
||||
* `TARGET=<target_id> FEDERATION=1 STAGE=local TOKEN=<access_token> pnpm seed:schemas`
|
||||
*/
|
||||
import { parse as parsePath } from 'path';
|
||||
import { printSchema } from 'graphql';
|
||||
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
|
||||
import { loadSchema, loadTypedefs } from '@graphql-tools/load';
|
||||
|
||||
const token = process.env.TOKEN || process.env.HIVE_TOKEN;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Missing "TOKEN"');
|
||||
}
|
||||
|
||||
const target = process.env.TARGET;
|
||||
const isFederation = process.env.FEDERATION === '1';
|
||||
const stage = process.env.STAGE || 'local';
|
||||
|
||||
let graphqlEndpoint: string;
|
||||
let environment: string;
|
||||
switch (stage.toLowerCase()) {
|
||||
case 'staging': {
|
||||
graphqlEndpoint = 'https://app.hiveready.dev/graphql';
|
||||
environment = 'staging';
|
||||
break;
|
||||
}
|
||||
case 'dev': {
|
||||
graphqlEndpoint = 'https://app.buzzcheck.dev/graphql';
|
||||
environment = 'dev';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
graphqlEndpoint = 'http://localhost:3001/graphql';
|
||||
environment = 'local';
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Environment: ${environment}
|
||||
Hive GraphQL endpoint: ${graphqlEndpoint}
|
||||
Schema type: ${isFederation ? 'federation' : 'single'}
|
||||
`);
|
||||
|
||||
const publishMutationDocument =
|
||||
/* GraphQL */
|
||||
`
|
||||
mutation schemaPublish($input: SchemaPublishInput!) {
|
||||
schemaPublish(input: $input) {
|
||||
__typename
|
||||
... on SchemaPublishSuccess {
|
||||
initial
|
||||
valid
|
||||
message
|
||||
linkToWebsite
|
||||
changes {
|
||||
nodes {
|
||||
message
|
||||
criticality
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
... on SchemaPublishError {
|
||||
valid
|
||||
linkToWebsite
|
||||
changes {
|
||||
nodes {
|
||||
message
|
||||
criticality
|
||||
}
|
||||
total
|
||||
}
|
||||
errors {
|
||||
nodes {
|
||||
message
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function publishSchema(args: { sdl: string; service?: string; target?: string }) {
|
||||
const response = await fetch(graphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: publishMutationDocument,
|
||||
variables: {
|
||||
input: {
|
||||
author: 'MoneyBoy',
|
||||
commit: '1977',
|
||||
sdl: args.sdl,
|
||||
service: args.service,
|
||||
url: `https://${args.service ? `${args.service}.` : ''}localhost/graphql`,
|
||||
target: args.target ? { byId: args.target } : null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).then(res => res.json());
|
||||
return response as {
|
||||
data: {
|
||||
schemaPublish: {
|
||||
valid: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
errors?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
async function single() {
|
||||
const schema = await loadSchema('scripts/seed-schemas/mono.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const sdl = printSchema(schema);
|
||||
const result = await publishSchema({
|
||||
sdl,
|
||||
target,
|
||||
});
|
||||
if (result?.errors || result?.data?.schemaPublish?.valid !== true) {
|
||||
console.error(`Published schema is invalid.`);
|
||||
} else {
|
||||
console.log(`Published successfully.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function federation() {
|
||||
const schemaDocs = await loadTypedefs('scripts/seed-schemas/federated/*.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const uploads = schemaDocs
|
||||
.map(async d => {
|
||||
if (!d.rawSDL) {
|
||||
console.error(`Missing SDL at "${d.location}"`);
|
||||
return null;
|
||||
}
|
||||
const service = d.location ? parsePath(d.location).name.replaceAll('.', '-') : undefined;
|
||||
|
||||
const result = await publishSchema({
|
||||
sdl: d.rawSDL,
|
||||
service,
|
||||
target,
|
||||
});
|
||||
|
||||
if (result?.errors || result?.data?.schemaPublish?.valid !== true) {
|
||||
console.error(`Published schema is invalid for "${service}".`);
|
||||
} else {
|
||||
console.log(`Published "${service}" successfully.`);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return Promise.all(uploads);
|
||||
}
|
||||
|
||||
if (isFederation === false) {
|
||||
await single();
|
||||
} else {
|
||||
await federation();
|
||||
}
|
||||
1
scripts/seed-schemas/federated/README.md
Normal file
1
scripts/seed-schemas/federated/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Each file in this directory is treated as a separate subgraph in the Federated seed.
|
||||
27
scripts/seed-schemas/federated/inventory.graphql
Normal file
27
scripts/seed-schemas/federated/inventory.graphql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@shareable", "@external", "@requires"]
|
||||
)
|
||||
|
||||
type Product @key(fields: "upc dimensions") {
|
||||
upc: String!
|
||||
dimensions: ProductDimension @external
|
||||
delivery(zip: String): DeliveryEstimates @requires(fields: "dimensions { size weight }")
|
||||
}
|
||||
|
||||
type ProductDimension @shareable {
|
||||
size: String
|
||||
weight: Float
|
||||
}
|
||||
|
||||
type DeliveryEstimates {
|
||||
estimatedDelivery: String
|
||||
fastestDelivery: String
|
||||
}
|
||||
|
||||
enum ShippingClass {
|
||||
STANDARD
|
||||
EXPRESS
|
||||
OVERNIGHT
|
||||
}
|
||||
63
scripts/seed-schemas/federated/products.graphql
Normal file
63
scripts/seed-schemas/federated/products.graphql
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@shareable", "@inaccessible", "@tag", "@external"]
|
||||
)
|
||||
|
||||
directive @example(text: String!) on FIELD_DEFINITION
|
||||
|
||||
type Query {
|
||||
allProducts: [ProductItf]
|
||||
product(upc: String!): ProductItf
|
||||
topProducts(first: Int = 5): [ProductItf]
|
||||
}
|
||||
|
||||
interface ProductItf implements SkuItf {
|
||||
upc: String!
|
||||
sku: String
|
||||
name: String
|
||||
package: String
|
||||
variations: [ProductItf]
|
||||
dimensions: ProductDimension
|
||||
createdBy: User
|
||||
hidden: String @inaccessible
|
||||
oldField: String @deprecated(reason: "refactored out")
|
||||
}
|
||||
|
||||
interface SkuItf {
|
||||
sku: String
|
||||
}
|
||||
|
||||
type Product implements ProductItf & SkuItf @key(fields: "upc") @key(fields: "sku package") {
|
||||
"""
|
||||
Universal Product Code. A standardized numeric global identifier
|
||||
"""
|
||||
upc: String!
|
||||
"""
|
||||
SKUs are unique to the company and are used internally. Alphanumeric.
|
||||
"""
|
||||
sku: String @tag(name: "internal")
|
||||
name: String @example(text: "Foo Bar")
|
||||
package: String
|
||||
variations: [ProductItf]
|
||||
dimensions: ProductDimension
|
||||
createdBy: User
|
||||
hidden: String
|
||||
reviewsScore: Float!
|
||||
oldField: String
|
||||
}
|
||||
|
||||
enum ShippingClass {
|
||||
STANDARD
|
||||
EXPRESS
|
||||
}
|
||||
|
||||
type ProductDimension @shareable {
|
||||
size: String
|
||||
weight: Float
|
||||
}
|
||||
|
||||
type User @key(fields: "id") {
|
||||
id: ID!
|
||||
totalProductsCreated: Int @shareable
|
||||
}
|
||||
28
scripts/seed-schemas/federated/reviews.graphql
Normal file
28
scripts/seed-schemas/federated/reviews.graphql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
extend schema
|
||||
@link(
|
||||
url: "https://specs.apollo.dev/federation/v2.3"
|
||||
import: ["@key", "@override", "@external", "@provides"]
|
||||
)
|
||||
|
||||
type Query {
|
||||
review(id: Int!): Review
|
||||
}
|
||||
|
||||
type Review @key(fields: "id") {
|
||||
id: ID!
|
||||
body: String
|
||||
author: User
|
||||
product: Product
|
||||
}
|
||||
|
||||
extend type User @key(fields: "id") {
|
||||
id: ID!
|
||||
reviews: [Review]
|
||||
}
|
||||
|
||||
extend type Product @key(fields: "upc") {
|
||||
upc: String!
|
||||
reviews: [Review]
|
||||
reviewsCount: Int!
|
||||
reviewsScore: Float! @override(from: "products")
|
||||
}
|
||||
20
scripts/seed-schemas/federated/users.graphql
Normal file
20
scripts/seed-schemas/federated/users.graphql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
extend schema
|
||||
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@tag", "@shareable"])
|
||||
directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT
|
||||
|
||||
type User @key(fields: "id") @key(fields: "email") {
|
||||
id: ID!
|
||||
email: String! @tag(name: "pii")
|
||||
name: String
|
||||
alias: String
|
||||
totalProductsCreated: Int @shareable
|
||||
createdAt: DateTime
|
||||
}
|
||||
|
||||
scalar DateTime
|
||||
|
||||
extend type Query {
|
||||
me: User
|
||||
user(id: ID!): User
|
||||
users: [User]
|
||||
}
|
||||
72
scripts/seed-schemas/mono.graphql
Normal file
72
scripts/seed-schemas/mono.graphql
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
interface Node {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
interface Character implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
friends: [Character]
|
||||
appearsIn: [Episode]!
|
||||
}
|
||||
|
||||
type Human implements Character & Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
friends: [Character]
|
||||
appearsIn: [Episode]!
|
||||
starships: [Starship]
|
||||
totalCredits: Int
|
||||
}
|
||||
|
||||
type Droid implements Character & Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
friends: [Character]
|
||||
appearsIn: [Episode]!
|
||||
primaryFunction: String
|
||||
}
|
||||
|
||||
type Starship {
|
||||
id: ID!
|
||||
name: String!
|
||||
length(unit: LengthUnit = METER): Float
|
||||
}
|
||||
|
||||
enum LengthUnit {
|
||||
METER
|
||||
LIGHT_YEAR
|
||||
}
|
||||
|
||||
enum Episode {
|
||||
"Star Wars Episode IV: A New Hope, released in 1977."
|
||||
NEWHOPE
|
||||
"Star Wars Episode V: The Empire Strikes Back, released in 1980."
|
||||
EMPIRE
|
||||
"Star Wars Episode VI: Return of the Jedi, released in 1983."
|
||||
JEDI
|
||||
}
|
||||
|
||||
"""
|
||||
The query type, represents all of the entry points into our object graph
|
||||
"""
|
||||
type Query {
|
||||
"""
|
||||
Fetches the hero of a specified Star Wars film.
|
||||
"""
|
||||
hero("The name of the film that the hero appears in." episode: Episode): Character
|
||||
}
|
||||
|
||||
type Review {
|
||||
episode: Episode
|
||||
stars: Int!
|
||||
commentary: String
|
||||
}
|
||||
|
||||
input ReviewInput {
|
||||
stars: Int!
|
||||
commentary: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createReview(episode: Episode, review: ReviewInput!): Review
|
||||
}
|
||||
175
scripts/seed-usage.ts
Normal file
175
scripts/seed-usage.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Example:
|
||||
* `TARGET=<target_id> FEDERATION=1 STAGE=local TOKEN=<access_token> pnpm seed:usage`
|
||||
*/
|
||||
import { parse as parsePath } from 'path';
|
||||
import { buildSchema, DocumentNode, GraphQLSchema, parse, print } from 'graphql';
|
||||
import { createHive, HiveClient } from '@graphql-hive/core';
|
||||
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
|
||||
import { loadDocuments, loadSchema, loadTypedefs } from '@graphql-tools/load';
|
||||
import {
|
||||
composeServices,
|
||||
ServiceDefinition,
|
||||
transformSupergraphToPublicSchema,
|
||||
} from '@theguild/federation-composition';
|
||||
|
||||
const token = process.env.TOKEN || process.env.HIVE_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error('Missing "TOKEN"');
|
||||
}
|
||||
|
||||
const target = process.env.TARGET;
|
||||
const isFederation = process.env.FEDERATION === '1';
|
||||
const stage = process.env.STAGE || 'local';
|
||||
const batches = parseInt(process.env.BATCHES || '10', 10) || 10;
|
||||
const operationsPerBatch = parseInt(process.env.OPERATIONS || '10', 10) || 10;
|
||||
const interval = parseInt(process.env.INTERVAL || '1000') || 1000;
|
||||
|
||||
let usageReportingEndpoint: string;
|
||||
let environment: string;
|
||||
switch (stage.toLowerCase()) {
|
||||
case 'staging': {
|
||||
usageReportingEndpoint = 'https://app.hiveready.dev/usage';
|
||||
environment = 'staging';
|
||||
break;
|
||||
}
|
||||
case 'dev': {
|
||||
usageReportingEndpoint = 'https://app.buzzcheck.dev/usage';
|
||||
environment = 'dev';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
usageReportingEndpoint = 'http://localhost:4001';
|
||||
environment = 'local';
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Environment: ${environment}
|
||||
Usage reporting endpoint: ${usageReportingEndpoint}
|
||||
`);
|
||||
|
||||
const createInstance = () => {
|
||||
return createHive({
|
||||
token,
|
||||
agent: {
|
||||
name: 'Hive Seed Script',
|
||||
maxSize: 10,
|
||||
},
|
||||
debug: true,
|
||||
enabled: true,
|
||||
usage: {
|
||||
target: target || undefined,
|
||||
clientInfo: () => ({
|
||||
// @todo create multiple clients with different versions and/or names
|
||||
name: 'Fake Hive Client',
|
||||
version: '1.1.1',
|
||||
}),
|
||||
endpoint: usageReportingEndpoint,
|
||||
max: 10,
|
||||
sampleRate: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Not very precise, but provides enough randomization to weight the queries reasonably well.
|
||||
*/
|
||||
const chooseQuery = (queries: DocumentNode[]) => {
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const randNumber = Math.random() * 100;
|
||||
if (randNumber <= 25) {
|
||||
return queries[i];
|
||||
}
|
||||
}
|
||||
return queries[queries.length - 1];
|
||||
};
|
||||
|
||||
function start(args: { instance: HiveClient; schema: GraphQLSchema; queries: DocumentNode[] }) {
|
||||
let sentBatches = 0;
|
||||
let intervalId: NodeJS.Timeout | null = setInterval(async () => {
|
||||
if (sentBatches >= batches) {
|
||||
console.log('Done.');
|
||||
if (!!intervalId) {
|
||||
clearInterval(intervalId);
|
||||
await args.instance.dispose();
|
||||
}
|
||||
intervalId = null;
|
||||
return;
|
||||
}
|
||||
sentBatches++;
|
||||
for (let i = 0; i < operationsPerBatch; i++) {
|
||||
const randNumber = Math.random() * 100;
|
||||
const done = args.instance.collectUsage();
|
||||
|
||||
await done(
|
||||
{
|
||||
document: chooseQuery(args.queries),
|
||||
schema: args.schema,
|
||||
variableValues: {},
|
||||
contextValue: {},
|
||||
},
|
||||
randNumber > 95
|
||||
? {
|
||||
errors: undefined,
|
||||
}
|
||||
: {
|
||||
errors: [{ message: 'oops' }],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
const instance = createInstance();
|
||||
await instance.info();
|
||||
if (isFederation === false) {
|
||||
const schema = await loadSchema('scripts/seed-schemas/mono.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const documents = await loadDocuments('scripts/seed-usage/mono/*.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const queries = documents.map(({ document }) => {
|
||||
if (!document) {
|
||||
throw new Error('Unexpected error. Could not find document.');
|
||||
}
|
||||
return document;
|
||||
});
|
||||
start({ instance, schema, queries });
|
||||
} else {
|
||||
const schemaDocs = await loadTypedefs('scripts/seed-schemas/federated/*.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const services = schemaDocs.map((d): ServiceDefinition => {
|
||||
if (!d.rawSDL) {
|
||||
throw new Error(`Missing SDL at "${d.location}"`);
|
||||
}
|
||||
if (!d.location) {
|
||||
throw new Error(`Unexpected error. Missing "location".`);
|
||||
}
|
||||
const service = parsePath(d.location).name.replaceAll('.', '-');
|
||||
|
||||
return {
|
||||
typeDefs: parse(d.rawSDL),
|
||||
name: service,
|
||||
url: `https://${service ? `${service}.` : ''}localhost/graphql`,
|
||||
};
|
||||
});
|
||||
|
||||
const { supergraphSdl, errors } = composeServices(services);
|
||||
if (errors) {
|
||||
throw new Error(`Could not compose:\n - ${errors.map(e => e.message).join('\n - ')}`);
|
||||
}
|
||||
const apiSchema = print(transformSupergraphToPublicSchema(parse(supergraphSdl)));
|
||||
const documents = await loadDocuments('scripts/seed-usage/federated/*.graphql', {
|
||||
loaders: [new GraphQLFileLoader()],
|
||||
});
|
||||
const queries = documents.map(({ document }) => {
|
||||
if (!document) {
|
||||
throw new Error('Unexpected error. Could not find document.');
|
||||
}
|
||||
return document;
|
||||
});
|
||||
start({ instance, schema: buildSchema(apiSchema), queries });
|
||||
}
|
||||
4
scripts/seed-usage/README.md
Normal file
4
scripts/seed-usage/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
seed-usage.ts will randomly select operations from these files to send as usage metrics.
|
||||
|
||||
Technically, these operations dont need to be valid against your schema(s). But they are separated
|
||||
into folders to create more realistic usage examples.
|
||||
11
scripts/seed-usage/federated/all-products.graphql
Normal file
11
scripts/seed-usage/federated/all-products.graphql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
query AllProducts {
|
||||
allProducts {
|
||||
id
|
||||
sku
|
||||
name
|
||||
variation {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
5
scripts/seed-usage/federated/first-review.graphql
Normal file
5
scripts/seed-usage/federated/first-review.graphql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
query FirstReview {
|
||||
review(id: 1) {
|
||||
body
|
||||
}
|
||||
}
|
||||
5
scripts/seed-usage/mono/new-hope-hero.graphql
Normal file
5
scripts/seed-usage/mono/new-hope-hero.graphql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
query NewHopeHero {
|
||||
hero(episode: NEWHOPE) {
|
||||
name
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue