diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d07402124..eb084267c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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= TARGET= 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= TARGET= 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=` 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 diff --git a/package.json b/package.json index 20ee846a2..c04bf1267 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c75f74931..1405254be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/seed-local-env.ts b/scripts/seed-local-env.ts deleted file mode 100644 index 0bac0df20..000000000 --- a/scripts/seed-local-env.ts +++ /dev/null @@ -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(); -} diff --git a/scripts/seed-schemas.ts b/scripts/seed-schemas.ts new file mode 100644 index 000000000..616387c33 --- /dev/null +++ b/scripts/seed-schemas.ts @@ -0,0 +1,168 @@ +/** + * Example: + * `TARGET= FEDERATION=1 STAGE=local 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(); +} diff --git a/scripts/seed-schemas/federated/README.md b/scripts/seed-schemas/federated/README.md new file mode 100644 index 000000000..74763dca5 --- /dev/null +++ b/scripts/seed-schemas/federated/README.md @@ -0,0 +1 @@ +Each file in this directory is treated as a separate subgraph in the Federated seed. diff --git a/scripts/seed-schemas/federated/inventory.graphql b/scripts/seed-schemas/federated/inventory.graphql new file mode 100644 index 000000000..a32b1ec78 --- /dev/null +++ b/scripts/seed-schemas/federated/inventory.graphql @@ -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 +} diff --git a/scripts/seed-schemas/federated/products.graphql b/scripts/seed-schemas/federated/products.graphql new file mode 100644 index 000000000..8c3d50a29 --- /dev/null +++ b/scripts/seed-schemas/federated/products.graphql @@ -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 +} diff --git a/scripts/seed-schemas/federated/reviews.graphql b/scripts/seed-schemas/federated/reviews.graphql new file mode 100644 index 000000000..ab52f89f5 --- /dev/null +++ b/scripts/seed-schemas/federated/reviews.graphql @@ -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") +} diff --git a/scripts/seed-schemas/federated/users.graphql b/scripts/seed-schemas/federated/users.graphql new file mode 100644 index 000000000..fe78ad734 --- /dev/null +++ b/scripts/seed-schemas/federated/users.graphql @@ -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] +} diff --git a/scripts/seed-schemas/mono.graphql b/scripts/seed-schemas/mono.graphql new file mode 100644 index 000000000..1c5775c55 --- /dev/null +++ b/scripts/seed-schemas/mono.graphql @@ -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 +} diff --git a/scripts/seed-usage.ts b/scripts/seed-usage.ts new file mode 100644 index 000000000..7705cfb95 --- /dev/null +++ b/scripts/seed-usage.ts @@ -0,0 +1,175 @@ +/** + * Example: + * `TARGET= FEDERATION=1 STAGE=local 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 }); +} diff --git a/scripts/seed-usage/README.md b/scripts/seed-usage/README.md new file mode 100644 index 000000000..e801bdc30 --- /dev/null +++ b/scripts/seed-usage/README.md @@ -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. diff --git a/scripts/seed-usage/federated/all-products.graphql b/scripts/seed-usage/federated/all-products.graphql new file mode 100644 index 000000000..d4be46a5c --- /dev/null +++ b/scripts/seed-usage/federated/all-products.graphql @@ -0,0 +1,11 @@ +query AllProducts { + allProducts { + id + sku + name + variation { + id + name + } + } +} diff --git a/scripts/seed-usage/federated/first-review.graphql b/scripts/seed-usage/federated/first-review.graphql new file mode 100644 index 000000000..8340670aa --- /dev/null +++ b/scripts/seed-usage/federated/first-review.graphql @@ -0,0 +1,5 @@ +query FirstReview { + review(id: 1) { + body + } +} diff --git a/scripts/seed-usage/mono/new-hope-hero.graphql b/scripts/seed-usage/mono/new-hope-hero.graphql new file mode 100644 index 000000000..19e5fe6ea --- /dev/null +++ b/scripts/seed-usage/mono/new-hope-hero.graphql @@ -0,0 +1,5 @@ +query NewHopeHero { + hero(episode: NEWHOPE) { + name + } +}