├── .prettierignore ├── src ├── core │ ├── constants.ts │ ├── util.ts │ ├── health.ts │ ├── models │ │ ├── graphModel.ts │ │ ├── schemaTagModel.ts │ │ ├── schemaModel.ts │ │ └── serviceModel.ts │ ├── env.schema.ts │ ├── validator-plugin.ts │ ├── shared-schemas.ts │ ├── types.ts │ ├── hook-handler │ │ └── user-scope.prevalidation.ts │ ├── errors.ts │ ├── knex-plugin.ts │ ├── federation.ts │ ├── basic-auth.ts │ ├── graphql-utils.ts │ ├── health.test.ts │ ├── jwt-auth.ts │ ├── repositories │ │ ├── SchemaTagRepository.ts │ │ ├── GraphRepository.ts │ │ ├── ServiceRepository.ts │ │ └── SchemaRepository.ts │ ├── basic-auth.test.ts │ ├── test-util.ts │ └── manager │ │ └── SchemaManager.ts ├── registry │ ├── federation │ │ ├── supergraph-schema.test.ts.snap │ │ ├── list-graphs.ts │ │ ├── deactivate-schema.ts │ │ ├── supergraph-schema.test.ts.md │ │ ├── supergraph-schema.test.ts.md.3648652584 │ │ ├── list-graphs.test.ts │ │ ├── supergraph-schema.test.ts │ │ ├── deactivate-schema.test.ts │ │ ├── compose-schema.ts │ │ ├── compose-schema-versions.ts │ │ ├── supergraph-schema.ts │ │ ├── compose-schema.test.ts │ │ ├── register-schema.ts │ │ └── compose-schema-versions.test.ts │ ├── index.ts │ ├── maintanance │ │ ├── garbage-collect.test.ts │ │ └── garbage-collect.ts │ ├── schema-validation │ │ ├── schema-validation.ts │ │ ├── schema-validation.test.ts │ │ ├── schema-coverage.ts │ │ ├── schema-check.ts │ │ ├── schema-coverage.test.ts │ │ └── schema-check.test.ts │ └── document-validation │ │ ├── document-validation.ts │ │ └── document-validation.test.ts ├── knexfile.ts ├── index.ts ├── build-server.ts └── migrations │ └── 20210504193054_initial_schema.ts ├── .gitignore ├── .dockerignore ├── docs ├── terminology.png ├── logo-usecases.png ├── logo-standalone.png ├── auth.md └── api.md ├── examples ├── apollo-federation │ ├── jsconfig.json │ ├── README.md │ ├── package.json │ ├── accounts.js │ ├── inventory.js │ ├── products.js │ ├── gateway.js │ └── reviews.js ├── mercurius-federation │ ├── jsconfig.json │ ├── README.md │ ├── package.json │ ├── gateway.js │ ├── accounts.js │ ├── inventory.js │ ├── products.js │ └── reviews.js └── apollo-managed-federation │ ├── jsconfig.json │ ├── README.md │ ├── package.json │ ├── gateway.js │ ├── accounts.js │ ├── inventory.js │ ├── products.js │ └── reviews.js ├── scripts ├── build-push.sh └── wait-for-healthy-container.sh ├── .prettierrc ├── .env.example ├── .husky └── pre-commit ├── .github ├── ISSUE_TEMPLATE │ ├── other-issue.md │ ├── feature-enhancement-request.md │ └── bug-report.md └── workflows │ ├── ci.yml │ └── bench.yml ├── Dockerfile ├── tsconfig.json ├── .vscode └── launch.json ├── docker-compose.yml ├── benchmark └── composed-schema.js ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── Insomnia.json /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .nyc_output 3 | dist 4 | insomnia.json -------------------------------------------------------------------------------- /src/core/constants.ts: -------------------------------------------------------------------------------- 1 | export const CURRENT_VERSION = 'current' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | node_modules/ 3 | /.nyc_output 4 | coverage 5 | .env -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .env 5 | /.github 6 | /.vscode 7 | /build -------------------------------------------------------------------------------- /docs/terminology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarpTech/graphql-registry/HEAD/docs/terminology.png -------------------------------------------------------------------------------- /docs/logo-usecases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarpTech/graphql-registry/HEAD/docs/logo-usecases.png -------------------------------------------------------------------------------- /docs/logo-standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarpTech/graphql-registry/HEAD/docs/logo-standalone.png -------------------------------------------------------------------------------- /examples/apollo-federation/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkJs": true, 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/mercurius-federation/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkJs": true, 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkJs": true, 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker build -t graphql-registry:latest . 4 | docker push starptech/graphql-registry:latest -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:changeme@localhost:5440/graphql-registry?schema=public" 2 | BASIC_AUTH=123,456 3 | JWT_SECRET=secret 4 | PRETTY_PRINT=true -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test -- --fail-fast --tap 5 | 6 | npx -p prettier@latest -p pretty-quick pretty-quick --staged -------------------------------------------------------------------------------- /src/core/util.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto' 2 | 3 | export function hash(data: string) { 4 | return createHash('sha256').update(data).digest('hex') 5 | } 6 | -------------------------------------------------------------------------------- /src/registry/federation/supergraph-schema.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StarpTech/graphql-registry/HEAD/src/registry/federation/supergraph-schema.test.ts.snap -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other issue or question 3 | about: Free form issue or question 4 | title: '' 5 | labels: carvel-triage 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /src/core/health.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | 3 | export default fp(async function Health(fastify, opts) { 4 | fastify.get('/health', async (req, res) => { 5 | await fastify.knexHealthcheck() 6 | res.send() 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/core/models/graphModel.ts: -------------------------------------------------------------------------------- 1 | export class GraphDBModel { 2 | id!: number 3 | name!: string 4 | isActive?: boolean 5 | createdAt!: Date 6 | updatedAt?: Date 7 | 8 | static table = 'graph' 9 | static fullName = (name: keyof GraphDBModel) => GraphDBModel.table + '.' + name 10 | static field = (name: keyof GraphDBModel) => name 11 | } 12 | -------------------------------------------------------------------------------- /src/core/models/schemaTagModel.ts: -------------------------------------------------------------------------------- 1 | export class SchemaTagDBModel { 2 | id!: number 3 | version!: string 4 | isActive?: boolean 5 | createdAt!: Date 6 | schemaId!: number 7 | serviceId!: number 8 | 9 | static table = 'schema_tag' 10 | static fullName = (name: keyof SchemaTagDBModel) => SchemaTagDBModel.table + '.' + name 11 | static field = (name: keyof SchemaTagDBModel) => name 12 | } 13 | -------------------------------------------------------------------------------- /src/core/models/schemaModel.ts: -------------------------------------------------------------------------------- 1 | export class SchemaDBModel { 2 | id!: number 3 | typeDefs!: string 4 | isActive?: boolean 5 | createdAt!: Date 6 | updatedAt?: Date 7 | graphId!: number 8 | serviceId!: number 9 | 10 | static table = 'schema' 11 | static fullName = (name: keyof SchemaDBModel) => SchemaDBModel.table + '.' + name 12 | static field = (name: keyof SchemaDBModel) => name 13 | } 14 | -------------------------------------------------------------------------------- /src/core/models/serviceModel.ts: -------------------------------------------------------------------------------- 1 | export class ServiceDBModel { 2 | id!: number 3 | name!: string 4 | isActive?: boolean 5 | createdAt!: Date 6 | updatedAt?: Date 7 | routingUrl?: string 8 | graphId!: number 9 | 10 | static table = 'service' 11 | static fullName = (name: keyof ServiceDBModel) => ServiceDBModel.table + '.' + name 12 | static field = (name: keyof ServiceDBModel) => name 13 | } 14 | -------------------------------------------------------------------------------- /src/knexfile.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import dotenv from 'dotenv' 3 | 4 | // allows to use .env for development because migrations must be compiled 5 | dotenv.config({ path: join(__dirname, '..', '.env') }) 6 | 7 | export default { 8 | client: 'pg', 9 | connection: process.env.DATABASE_URL, 10 | searchPath: [process.env.DATABASE_SCHEMA || 'public'], 11 | migrations: { 12 | extension: 'ts', 13 | directory: join(__dirname, '/migrations'), 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine as builder 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | RUN npm prune --production 14 | 15 | FROM node:14-alpine as runner 16 | 17 | COPY --from=builder /usr/src/app/node_modules node_modules 18 | COPY --from=builder /usr/src/app/build build 19 | COPY --from=builder /usr/src/app/package.json . 20 | 21 | ENV NODE_ENV=production 22 | EXPOSE 3000 23 | 24 | USER node 25 | 26 | CMD [ "node", "build/index.js" ] 27 | 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": false, 12 | "preserveConstEnums": true, 13 | "moduleResolution": "node", 14 | "sourceMap": true, 15 | "incremental": true, 16 | "esModuleInterop": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "**/node_modules/**"] 20 | } 21 | -------------------------------------------------------------------------------- /src/core/env.schema.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | 3 | export default S.object() 4 | .prop('DATABASE_URL', S.string()) 5 | .prop('DATABASE_SCHEMA', S.string().default('public')) 6 | .prop('BASIC_AUTH', S.string()) 7 | .prop('JWT_SECRET', S.string()) 8 | .prop('PRETTY_PRINT', S.boolean().default(false)) 9 | .prop('LOGGER', S.boolean().default(true)) as any 10 | 11 | export interface AppSchema { 12 | DATABASE_URL: string 13 | DATABASE_SCHEMA: string 14 | PRETTY_PRINT: boolean 15 | LOGGER: boolean 16 | BASIC_AUTH: string 17 | JWT_SECRET: string 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import build from './build-server' 2 | import envSchema from 'env-schema' 3 | import appSchema, { AppSchema } from './core/env.schema' 4 | 5 | const config = envSchema({ 6 | schema: appSchema, 7 | }) as any as AppSchema 8 | 9 | const app = build({ 10 | databaseConnectionUrl: config.DATABASE_URL, 11 | databaseSchema: config.DATABASE_SCHEMA, 12 | basicAuth: config.BASIC_AUTH, 13 | jwtSecret: config.JWT_SECRET, 14 | logger: config.LOGGER, 15 | prettyPrint: config.PRETTY_PRINT, 16 | }) 17 | 18 | app.listen(3000, '0.0.0.0', (err, address) => { 19 | if (err) throw err 20 | console.log(`Server listening at ${address}`) 21 | }) 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug AVA test file", 11 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ava", 12 | "runtimeArgs": ["--serial", "${file}"], 13 | "outputCapture": "std", 14 | "skipFiles": ["/**/*.js", "${workspaceFolder}/node_modules/**/*.js"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/core/validator-plugin.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { Ajv as AjvInstance } from 'ajv' 2 | import fp from 'fastify-plugin' 3 | 4 | declare module 'fastify' { 5 | interface FastifyInstance { 6 | ajv: AjvInstance 7 | } 8 | } 9 | 10 | export default fp(async function (fastify, opts) { 11 | const ajv = new Ajv({ 12 | coerceTypes: true, 13 | useDefaults: true, 14 | removeAdditional: true, 15 | // Explicitly set allErrors to `false`. 16 | // When set to `true`, a DoS attack is possible. 17 | allErrors: false, 18 | }) 19 | 20 | fastify.decorate('ajv', ajv) 21 | 22 | fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => ajv.compile(schema)) 23 | }) 24 | -------------------------------------------------------------------------------- /src/core/shared-schemas.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | 3 | export const serviceName = S.string().minLength(1).maxLength(100).pattern('[a-zA-Z_\\-0-9]+') 4 | export const graphName = S.string().minLength(1).maxLength(100).pattern('[a-zA-Z_\\-0-9]+') 5 | export const version = S.string().minLength(1).maxLength(100).pattern('[a-zA-Z_\\-0-9]+') 6 | export const typeDefs = S.string().minLength(1).maxLength(10000) 7 | export const schemaId = S.integer().minimum(1) 8 | export const document = S.string().minLength(1).maxLength(10000) 9 | export const routingUrl = S.string().minLength(1).maxLength(10000).format(S.FORMATS.URI) 10 | export const dateTime = S.string().format(S.FORMATS.DATE_TIME) 11 | -------------------------------------------------------------------------------- /examples/mercurius-federation/README.md: -------------------------------------------------------------------------------- 1 | # Federation with [Mercurius](https://github.com/mercurius-js/mercurius) 2 | 3 | Federation allows you to split your unified schema in multiple pieces, managed by separate services. This has benefits for scaling and maintainability. 4 | 5 | 1. Run `npm run start-registry` 6 | 2. Run `npm run start-services` 7 | 3. Run `npm run start-gateway` 8 | 4. Visit playground `http://localhost:3002/playground` 9 | 10 | Try 11 | 12 | ```graphql 13 | { 14 | topProducts { 15 | name 16 | inStock 17 | shippingEstimate 18 | reviews { 19 | id 20 | author { 21 | name 22 | } 23 | product { 24 | name 25 | } 26 | } 27 | } 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /examples/apollo-federation/README.md: -------------------------------------------------------------------------------- 1 | # Federation with [Apollo Gateway](https://github.com/apollographql/federation) 2 | 3 | Federation allows you to split your unified schema in multiple pieces, managed by separate services. This has benefits for scaling and maintainability. 4 | 5 | 1. Run `npm run start-registry` 6 | 2. Run `npm run start-services` 7 | 3. Run `npm run start-gateway` 8 | 4. Visit playground `http://localhost:4000/playground` 9 | 10 | Try 11 | 12 | ```graphql 13 | { 14 | topProducts { 15 | name 16 | inStock 17 | shippingEstimate 18 | reviews { 19 | id 20 | author { 21 | name 22 | } 23 | product { 24 | name 25 | } 26 | } 27 | } 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication & Authorization 2 | 3 | ## Basic Auth 4 | 5 | You have to set `BASIC_AUTH=secret1,secret2` to enbale basic auth. The secret is used as user and pass combination. 6 | 7 | ## JWT Bearer Token 8 | 9 | You have to set `JWT_SECRET=secret` to enable jwt. The jwt payload must match the following schema: 10 | 11 | ```jsonc 12 | { 13 | "client": "my-service", // your unique client name 14 | "services": [] // additional services you have control over. 15 | ``` 16 | 17 | This activates authorization in the `/schema/push` endpoint. Only the client with a valid jwt is be able to register schemas in the name of the `services`. The client will have access to all available graphs. You can use [jwt.io](https://jwt.io/) to construct a valid jwt. 18 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/README.md: -------------------------------------------------------------------------------- 1 | # Managed federation with [Apollo Gateway](https://github.com/apollographql/federation) 2 | 3 | The difference between federation is that the gateway is no longer responsible to specify the service configurations. The entire composed graph is provided by a single versioned artifact to the gateway. 4 | 5 | 1. Run `npm run start-registry` 6 | 2. Run `npm run start-services` 7 | 3. Run `npm run start-gateway` 8 | 4. Visit playground `http://localhost:4000/playground` 9 | 10 | Try 11 | 12 | ```graphql 13 | { 14 | topProducts { 15 | name 16 | inStock 17 | shippingEstimate 18 | reviews { 19 | id 20 | author { 21 | name 22 | } 23 | product { 24 | name 25 | } 26 | } 27 | } 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDBModel } from './models/schemaModel' 2 | import { SchemaTagDBModel } from './models/schemaTagModel' 3 | 4 | export type SchemaResponseModel = { 5 | schemaId: number 6 | serviceName: string 7 | routingUrl?: string 8 | typeDefs: string 9 | version: string 10 | lastUpdatedAt: Date | undefined 11 | } & Pick 12 | 13 | export type ResponseModel = { 14 | success: boolean 15 | } 16 | 17 | export type SuccessResponse = { 18 | success: true 19 | data: T 20 | } 21 | export type ErrorResponse = { 22 | success: false 23 | error?: string 24 | } 25 | 26 | export interface ServiceVersionMatch { 27 | name: string 28 | version: string 29 | } 30 | 31 | export type LastUpdatedSchema = Pick & 32 | Pick 33 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-managed-federation", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start-registry": "cd ../.. && npm run dev", 8 | "start-gateway": "nodemon gateway.js", 9 | "start-service-accounts": "nodemon accounts.js", 10 | "start-service-reviews": "nodemon reviews.js", 11 | "start-service-products": "nodemon products.js", 12 | "start-service-inventory": "nodemon inventory.js", 13 | "start-services": "concurrently \"npm:start-service-*\"" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "nodemon": "latest", 19 | "concurrently": "latest", 20 | "httpie": "latest" 21 | }, 22 | "dependencies": { 23 | "@apollo/gateway": "^0.28.1", 24 | "apollo-server": "^2.24.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/apollo-federation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-federation", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start-registry": "cd ../.. && npm run dev", 8 | "start-gateway": "nodemon gateway.js", 9 | "start-service-accounts": "nodemon accounts.js", 10 | "start-service-reviews": "nodemon reviews.js", 11 | "start-service-products": "nodemon products.js", 12 | "start-service-inventory": "nodemon inventory.js", 13 | "start-services": "concurrently \"npm:start-service-*\"" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "nodemon": "latest", 19 | "concurrently": "latest", 20 | "httpie": "latest" 21 | }, 22 | "dependencies": { 23 | "@apollo/federation": "^0.25.0", 24 | "@apollo/gateway": "^0.28.1", 25 | "apollo-server": "^2.24.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/mercurius-federation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mercurius-federation", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start-registry": "cd ../.. && npm run dev", 8 | "start-gateway": "nodemon gateway.js", 9 | "start-service-accounts": "nodemon accounts.js", 10 | "start-service-reviews": "nodemon reviews.js", 11 | "start-service-products": "nodemon products.js", 12 | "start-service-inventory": "nodemon inventory.js", 13 | "start-services": "concurrently \"npm:start-service-*\"" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "nodemon": "latest", 19 | "concurrently": "latest", 20 | "httpie": "latest" 21 | }, 22 | "dependencies": { 23 | "@apollo/federation": "^0.25.0", 24 | "apollo-server": "^2.24.1", 25 | "mercurius": "^7.6.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/registry/federation/list-graphs.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifySchema } from 'fastify' 2 | import S from 'fluent-json-schema' 3 | import { GraphDBModel } from '../../core/models/graphModel' 4 | import { graphName } from '../../core/shared-schemas' 5 | 6 | export const schema: FastifySchema = { 7 | response: { 8 | '2xx': S.object() 9 | .additionalProperties(false) 10 | .required(['success']) 11 | .prop('success', S.boolean()) 12 | .prop('data', S.array().items(graphName)), 13 | }, 14 | } 15 | 16 | export default function listGraphs(fastify: FastifyInstance) { 17 | fastify.get('/graphs', async (req, res) => { 18 | const allGraphs = await fastify.knex 19 | .select(GraphDBModel.fullName('name')) 20 | .from(GraphDBModel.table) 21 | 22 | res.send({ 23 | success: true, 24 | data: allGraphs.map((graph) => graph.name), 25 | }) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/core/hook-handler/user-scope.prevalidation.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify' 2 | import { InvalidServiceScopeError } from '../errors' 3 | 4 | export interface RequestContext { 5 | Body: { serviceName: string } 6 | } 7 | 8 | /** 9 | * Validate if the client is able to register a schema in the name of the service 10 | */ 11 | export const checkUserServiceScope = function ( 12 | req: FastifyRequest, 13 | res: FastifyReply, 14 | next: HookHandlerDoneFunction, 15 | ) { 16 | // JWT context ? 17 | if (req.user && req.body.serviceName) { 18 | // client is always able to access its own service 19 | if (req.body.serviceName === req.user.client) { 20 | return next() 21 | } else if (!req.user.services.find((service) => service === req.body.serviceName)) { 22 | return next(InvalidServiceScopeError(req.body.serviceName)) 23 | } 24 | } 25 | next() 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | # trigger 4 | services: 5 | app: 6 | container_name: graphql-registry 7 | build: . 8 | ports: 9 | - '3001:3000' 10 | restart: unless-stopped 11 | environment: 12 | DATABASE_URL: postgresql://postgres:changeme@postgres:5432/graphql-registry?schema=public 13 | 14 | postgres: 15 | container_name: postgres 16 | image: postgres 17 | healthcheck: 18 | test: ['CMD-SHELL', 'pg_isready -U postgres'] 19 | interval: 10s 20 | timeout: 5s 21 | retries: 5 22 | environment: 23 | POSTGRES_USER: ${POSTGRES_USER:-postgres} 24 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} 25 | PGDATA: /data/postgres 26 | ports: 27 | - '5440:5432' 28 | restart: unless-stopped 29 | 30 | k6: 31 | container_name: k6 32 | image: loadimpact/k6:latest 33 | environment: 34 | URL: http://graphql-registry:3000 35 | depends_on: 36 | - app 37 | volumes: 38 | - ./benchmark:/benchmark 39 | profiles: 40 | - test 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-enhancement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for graphql-registry 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Describe the problem/challenge you have** 10 | [A description of the current challenge that you are experiencing.] 11 | 12 | **Describe the solution you'd like** 13 | [A clear and concise description of what you want to happen. If applicable a visual representation of the UX (ex: new CLI argument name, the behavior expected).] 14 | 15 | **Anything else you would like to add:** 16 | [Additional information that will assist in solving the issue.] 17 | 18 | --- 19 | 20 | Vote on this request 21 | 22 | This is an invitation to the community to vote on issues, to help us prioritize our backlog. Use the "smiley face" up to the right of this comment to vote. 23 | 24 | 👍 "I would like to see this addressed as soon as possible" 25 | 👎 "There are other more important things to focus on right now" 26 | 27 | We are also happy to receive and review Pull Requests if you want to help working on this issue. 28 | -------------------------------------------------------------------------------- /src/core/errors.ts: -------------------------------------------------------------------------------- 1 | import createError from 'fastify-error' 2 | 3 | export const InvalidServiceScopeError = createError( 4 | 'GR_INSUFFICIENT_SERVICE_SCOPE', 5 | `You are not authorized to access service "%s"`, 6 | 401, 7 | ) 8 | export const DuplicateServiceUrlError = createError( 9 | 'GR_DUPLICATE_SERVICE_URL', 10 | `Service "%s" already use the routingUrl "%s"`, 11 | 400, 12 | ) 13 | export const InvalidGraphNameError = createError( 14 | 'GR_INVALID_GRAPH_NAME', 15 | `Graph with name "%s" does not exist`, 16 | 400, 17 | ) 18 | export const SchemaCompositionError = createError('GR_SCHEMA_COMPOSITION', `%s`, 400) 19 | export const SchemaVersionLookupError = createError('GR_SCHEMA_VERSION_LOOKUP', `%s`, 400) 20 | export const SupergraphCompositionError = createError('GR_SUPERGRAPH_COMPOSITION', `%s`, 400) 21 | export const SchemaNotFoundError = createError( 22 | 'GR_SCHEMA_NOT_FOUND', 23 | `Could not find schema with id "%s"`, 24 | 400, 25 | ) 26 | export const InvalidDocumentError = createError( 27 | 'GR_DOCUMENT_INVALID', 28 | `Could not parse document`, 29 | 400, 30 | ) 31 | export const FatalError = createError('GR_FATAL', `%s`, 500) 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Tell us about a problem you are experiencing 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **What steps did you take:** 10 | [A clear and concise description steps and any files that can be used to reproduce the problem.] 11 | 12 | **What happened:** 13 | [A small description of the result] 14 | 15 | **What did you expect:** 16 | [A description of what was expected] 17 | 18 | **Anything else you would like to add:** 19 | [Additional information that will assist in solving the issue.] 20 | 21 | **Environment:** 22 | 23 | - graphql-registry version: 24 | - OS (e.g. from `/etc/os-release`): 25 | - Node.js version (use `node -v`) 26 | 27 | --- 28 | 29 | Vote on this request 30 | 31 | This is an invitation to the community to vote on issues, to help us prioritize our backlog. Use the "smiley face" up to the right of this comment to vote. 32 | 33 | 👍 "I would like to see this addressed as soon as possible" 34 | 👎 "There are other more important things to focus on right now" 35 | 36 | We are also happy to receive and review Pull Requests if you want to help working on this issue. 37 | -------------------------------------------------------------------------------- /src/core/knex-plugin.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import Knex from 'knex' 3 | 4 | declare module 'fastify' { 5 | interface FastifyInstance { 6 | knex: Knex 7 | knexHealthcheck(): Promise 8 | } 9 | } 10 | 11 | export interface KnexPluginOptions { 12 | databaseConnectionUrl: string 13 | databaseSchema?: string 14 | } 15 | 16 | export default fp(async function (fastify, opts) { 17 | const connection = Knex({ 18 | client: 'pg', 19 | log: { 20 | warn: fastify.log.warn, 21 | error: fastify.log.error, 22 | deprecate: fastify.log.info, 23 | debug: fastify.log.debug, 24 | }, 25 | connection: opts.databaseConnectionUrl, 26 | searchPath: opts.databaseSchema, 27 | }) 28 | 29 | fastify.decorate('knexHealthcheck', async () => { 30 | try { 31 | await connection.raw('SELECT NOW()') 32 | } catch (error) { 33 | fastify.log.error(error) 34 | throw new Error('Database connection healthcheck failed') 35 | } 36 | }) 37 | fastify.addHook('onClose', () => connection.destroy()) 38 | 39 | await fastify.knexHealthcheck() 40 | 41 | fastify.decorate('knex', connection) 42 | }) 43 | -------------------------------------------------------------------------------- /src/core/federation.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema, parse } from 'graphql' 2 | import { composeAndValidate } from '@apollo/federation' 3 | 4 | export interface ServiceSchema { 5 | typeDefs: string 6 | name: string 7 | url?: string 8 | } 9 | 10 | export interface CompositionResult { 11 | error: string | null 12 | schema: GraphQLSchema | null 13 | supergraphSdl: string | undefined 14 | } 15 | 16 | export function composeAndValidateSchema(servicesSchemaMap: ServiceSchema[]): CompositionResult { 17 | let result: CompositionResult = { 18 | error: null, 19 | schema: null, 20 | supergraphSdl: '', 21 | } 22 | 23 | try { 24 | const serviceList = servicesSchemaMap.map((schema) => { 25 | let typeDefs 26 | 27 | typeDefs = parse(schema.typeDefs) 28 | 29 | return { 30 | name: schema.name, 31 | url: schema.url, 32 | typeDefs, 33 | } 34 | }) 35 | 36 | const { schema, errors, supergraphSdl } = composeAndValidate(serviceList) 37 | if (!!errors) { 38 | result.error = `${errors[0]}` 39 | return result 40 | } 41 | result.schema = schema 42 | result.supergraphSdl = supergraphSdl 43 | } catch (err) { 44 | result.error = `${err.message}` 45 | } 46 | 47 | return result 48 | } 49 | -------------------------------------------------------------------------------- /src/core/basic-auth.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import { FastifyReply, FastifyRequest } from 'fastify' 3 | import basicAuth from 'fastify-basic-auth' 4 | import { timingSafeEqual } from 'crypto' 5 | 6 | export interface basicAuthOptions { 7 | basicAuthSecrets: string 8 | } 9 | 10 | export default fp(async function BasicAuth(fastify, opts) { 11 | async function validate( 12 | username: string, 13 | password: string, 14 | req: FastifyRequest, 15 | reply: FastifyReply, 16 | ) { 17 | if ( 18 | !opts.basicAuthSecrets || 19 | !opts.basicAuthSecrets 20 | .trim() 21 | .split(',') 22 | .find( 23 | (secret) => 24 | secret.length === username.length && 25 | secret.length === password.length && 26 | timingSafeEqual(Buffer.from(secret), Buffer.from(username)) && 27 | timingSafeEqual(Buffer.from(secret), Buffer.from(password)), 28 | ) 29 | ) { 30 | throw new Error('Invalid credentials') 31 | } 32 | } 33 | 34 | fastify.register(basicAuth, { 35 | authenticate: { realm: 'GraphQL Registry' }, 36 | validate, 37 | }) 38 | 39 | fastify.after(() => { 40 | fastify.addHook('onRequest', fastify.basicAuth) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/core/graphql-utils.ts: -------------------------------------------------------------------------------- 1 | import { ChangeType, coverage, CriticalityLevel, diff } from '@graphql-inspector/core' 2 | import { GraphQLSchema, Source, stripIgnoredCharacters } from 'graphql' 3 | 4 | export function normalizeSchema(typeDefs: string): string { 5 | // TODO sort fields and types 6 | return stripIgnoredCharacters(typeDefs) 7 | } 8 | 9 | export function getChangeSet(previous: GraphQLSchema, current: GraphQLSchema) { 10 | const changes = diff(previous, current) 11 | 12 | const changesReport = [] 13 | let breakingChangeAdded = false 14 | let deprecationAdded = false 15 | 16 | for (const change of changes) { 17 | if (change.criticality.level === CriticalityLevel.Breaking) { 18 | breakingChangeAdded = true 19 | } 20 | if (change.type === ChangeType.FieldDeprecationAdded) { 21 | deprecationAdded = true 22 | } 23 | 24 | changesReport.push({ 25 | type: change.type, 26 | message: change.message, 27 | level: change.criticality.level, 28 | path: change.path, 29 | reason: change.criticality.reason, 30 | }) 31 | } 32 | 33 | return { 34 | breakingChangeAdded, 35 | deprecationAdded, 36 | changes: changesReport, 37 | } 38 | } 39 | 40 | export function getSchemaCoverage(schema: GraphQLSchema, sources: Source[]) { 41 | return coverage(schema, sources) 42 | } 43 | -------------------------------------------------------------------------------- /src/core/health.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from './test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should return 200', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'GET', 18 | url: '/health', 19 | }) 20 | 21 | t.is(res.statusCode, 200) 22 | }) 23 | 24 | test('Should has a decorator to check the healthcheck of the connection', async (t) => { 25 | const app = build({ 26 | databaseConnectionUrl: t.context.connectionUrl, 27 | }) 28 | 29 | t.teardown(() => app.close()) 30 | 31 | await app.ready() 32 | 33 | t.true(app.hasDecorator('knexHealthcheck')) 34 | }) 35 | 36 | test('Should error because connection is invalid', async (t) => { 37 | const app = build({ 38 | databaseConnectionUrl: 'postgresql://postgres:changeme@foo:5440/bar?schema=public', 39 | }) 40 | 41 | t.teardown(() => app.close()) 42 | 43 | await t.throwsAsync(() => app.ready(), { message: 'Database connection healthcheck failed' }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/registry/federation/deactivate-schema.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { FastifyInstance, FastifySchema } from 'fastify' 3 | import { SchemaNotFoundError } from '../../core/errors' 4 | import SchemaRepository from '../../core/repositories/SchemaRepository' 5 | import { schemaId } from '../../core/shared-schemas' 6 | 7 | export interface RequestContext { 8 | Body: { 9 | schemaId: number 10 | graphName: string 11 | } 12 | } 13 | 14 | export const schema: FastifySchema = { 15 | response: { 16 | '2xx': S.object() 17 | .additionalProperties(false) 18 | .required(['success']) 19 | .prop('success', S.boolean()), 20 | }, 21 | body: S.object().additionalProperties(false).required(['schemaId']).prop('schemaId', schemaId), 22 | } 23 | 24 | export default function deactivateSchema(fastify: FastifyInstance) { 25 | fastify.put('/schema/deactivate', async (req, res) => { 26 | return fastify.knex.transaction(async function (trx) { 27 | const schemaRepository = new SchemaRepository(trx) 28 | const schema = await schemaRepository.findById(req.body.schemaId) 29 | 30 | if (!schema) { 31 | throw SchemaNotFoundError(req.body.schemaId) 32 | } 33 | 34 | await schemaRepository.updateById(schema.id, { 35 | ...schema, 36 | isActive: false, 37 | }) 38 | 39 | return { 40 | success: true, 41 | } 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/core/jwt-auth.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import fp from 'fastify-plugin' 3 | import { FastifyReply, FastifyRequest } from 'fastify' 4 | import jwtAuth from 'fastify-jwt' 5 | 6 | export interface jwtAuthOptions { 7 | secret: string 8 | } 9 | 10 | export interface JwtPayload { 11 | services: string[] 12 | client: string 13 | } 14 | 15 | const payloadSchema = S.object() 16 | .additionalProperties(false) 17 | .required(['services', 'client']) 18 | .prop('services', S.array().items(S.string().minLength(1).pattern('[a-zA-Z_\\-0-9]+'))) 19 | .prop('client', S.string().minLength(1).pattern('[a-zA-Z_\\-0-9]+')) 20 | 21 | declare module 'fastify-jwt' { 22 | interface FastifyJWT { 23 | payload: JwtPayload 24 | } 25 | } 26 | 27 | export default fp(async function JwtAuth(fastify, opts) { 28 | const payloadSchemavalidator = fastify.ajv.compile(payloadSchema.valueOf()) 29 | 30 | async function validate(req: FastifyRequest, reply: FastifyReply) { 31 | try { 32 | await req.jwtVerify() 33 | } catch (err) { 34 | reply.send({ 35 | success: false, 36 | error: err.toString(), 37 | }) 38 | } 39 | } 40 | 41 | fastify.register(jwtAuth, { 42 | secret: opts.secret, 43 | trusted: validateToken, 44 | }) 45 | 46 | async function validateToken(req: FastifyRequest, decodedToken: any) { 47 | return payloadSchemavalidator(decodedToken) 48 | } 49 | 50 | fastify.addHook('onRequest', validate) 51 | }) 52 | -------------------------------------------------------------------------------- /src/core/repositories/SchemaTagRepository.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { SchemaDBModel } from '../models/schemaModel' 3 | import { SchemaTagDBModel } from '../models/schemaTagModel' 4 | 5 | export default class SchemaTagRepository { 6 | private knex: Knex 7 | constructor(knex: Knex) { 8 | this.knex = knex 9 | } 10 | findFirst(what: Partial) { 11 | const knex = this.knex 12 | const table = SchemaTagDBModel.table 13 | return knex.from(table).where(what).first() 14 | } 15 | async create(entity: Omit) { 16 | const knex = this.knex 17 | const table = SchemaTagDBModel.table 18 | const [first] = await knex(table) 19 | .insert({ 20 | ...entity, 21 | createdAt: new Date(), 22 | }) 23 | .returning('*') 24 | 25 | return first 26 | } 27 | async deleteBySchemaId(schemaId: number) { 28 | const knex = this.knex 29 | const table = SchemaTagDBModel.table 30 | return await knex(table) 31 | .where(SchemaTagDBModel.field('schemaId'), schemaId) 32 | .delete() 33 | .returning[]>(SchemaTagDBModel.field('id')) 34 | } 35 | async update(what: Partial, where: Partial) { 36 | const knex = this.knex 37 | const table = SchemaTagDBModel.table 38 | return knex(table).update(what).where(where).returning('*') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/gateway.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { ApolloGateway } = require('@apollo/gateway') 3 | const { post } = require('httpie') 4 | 5 | async function getSupergraph() { 6 | const res = await post(`http://localhost:3000/schema/supergraph`, { 7 | body: { 8 | graphName: 'my_graph', 9 | }, 10 | }) 11 | 12 | return { 13 | supergraphSdl: res.data.data.supergraphSdl, 14 | id: res.data.data.compositionId, 15 | } 16 | } 17 | 18 | async function startServer() { 19 | const gateway = new ApolloGateway({ 20 | // fetch for schema or service updates every 30s 21 | experimental_pollInterval: 30000, 22 | 23 | async experimental_updateSupergraphSdl() { 24 | return getSupergraph() 25 | }, 26 | 27 | // Experimental: Enabling this enables the query plan view in Playground. 28 | __exposeQueryPlanExperimental: false, 29 | }) 30 | 31 | const server = new ApolloServer({ 32 | gateway, 33 | 34 | // Apollo Graph Manager (previously known as Apollo Engine) 35 | // When enabled and an `ENGINE_API_KEY` is set in the environment, 36 | // provides metrics, schema management and trace reporting. 37 | engine: false, 38 | 39 | // Subscriptions are unsupported but planned for a future Gateway version. 40 | subscriptions: false, 41 | }) 42 | 43 | server.listen().then(({ url }) => { 44 | console.log(`🚀 Server ready at ${url}`) 45 | }) 46 | } 47 | 48 | startServer().catch((err) => { 49 | console.error(err) 50 | process.exit(1) 51 | }) 52 | -------------------------------------------------------------------------------- /src/core/repositories/GraphRepository.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { GraphDBModel } from '../models/graphModel' 3 | 4 | export default class GraphRepository { 5 | private knex: Knex 6 | constructor(knex: Knex) { 7 | this.knex = knex 8 | } 9 | async exists({ name }: { name: string }) { 10 | const knex = this.knex 11 | const table = GraphDBModel.table 12 | const result = await knex 13 | .from(table) 14 | .count(GraphDBModel.fullName('id')) 15 | .where(GraphDBModel.fullName('name'), name) 16 | .first<{ count: number }>() 17 | 18 | return result.count > 0 19 | } 20 | findFirst({ name }: { name: string }) { 21 | const knex = this.knex 22 | const table = GraphDBModel.table 23 | return knex 24 | .from(table) 25 | .where(GraphDBModel.fullName('name'), name) 26 | .first() 27 | } 28 | async create(entity: Omit) { 29 | const knex = this.knex 30 | const table = GraphDBModel.table 31 | const [first] = await knex(table) 32 | .insert({ 33 | ...entity, 34 | createdAt: new Date(), 35 | updatedAt: new Date(), 36 | }) 37 | .returning('*') 38 | 39 | return first 40 | } 41 | async deleteByName(name: string) { 42 | const knex = this.knex 43 | const table = GraphDBModel.table 44 | return knex(table) 45 | .where(GraphDBModel.field('name'), name) 46 | .delete() 47 | .returning[]>(GraphDBModel.field('id')) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/mercurius-federation/gateway.js: -------------------------------------------------------------------------------- 1 | const Fastify = require('fastify') 2 | const mercurius = require('mercurius') 3 | const { post } = require('httpie') 4 | 5 | async function fetchServices() { 6 | const res = await post(`http://localhost:3000/schema/compose`, { 7 | body: { 8 | graphName: 'my_graph', 9 | services: [ 10 | { name: 'accounts', version: 'current' }, 11 | { name: 'inventory', version: 'current' }, 12 | { name: 'products', version: 'current' }, 13 | { name: 'reviews', version: 'current' }, 14 | ], 15 | }, 16 | }) 17 | 18 | return res.data.data.map((svc) => { 19 | return { 20 | name: svc.serviceName, 21 | url: svc.routingUrl, 22 | schema: svc.typeDefs, 23 | mandatory: true, 24 | } 25 | }) 26 | } 27 | 28 | async function startServer(services) { 29 | const server = Fastify() 30 | 31 | server.register(mercurius, { 32 | graphiql: 'playground', 33 | federationMetadata: true, 34 | gateway: { 35 | services: await fetchServices(), 36 | }, 37 | }) 38 | 39 | server.listen(3002, (err, address) => { 40 | if (err) throw err 41 | console.log(`Server is now listening on ${address}/playground`) 42 | }) 43 | 44 | setTimeout(async () => { 45 | const services = await fetchServices() 46 | for (const svc of services) { 47 | server.graphql.gateway.serviceMap[svc.name].setSchema(svc.schema) 48 | } 49 | 50 | const schema = await server.graphql.gateway.refresh() 51 | 52 | if (schema !== null) { 53 | server.graphql.replaceSchema(schema) 54 | } 55 | }, 30000) 56 | } 57 | 58 | startServer().catch((err) => { 59 | console.error(err) 60 | process.exit(1) 61 | }) 62 | -------------------------------------------------------------------------------- /src/registry/federation/supergraph-schema.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `src/registry/federation/supergraph-schema.test.ts` 2 | 3 | The actual snapshot is saved in `supergraph-schema.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## Should return supergraph of two services 8 | 9 | > supergraph composition 10 | 11 | { 12 | compositionId: '9edfd7b3195e3bec8183b6340538f5eb4bee8052798d8ca4f647534a585dacaa', 13 | supergraphSdl: `schema␊ 14 | @core(feature: "https://specs.apollo.dev/core/v0.1"),␊ 15 | @core(feature: "https://specs.apollo.dev/join/v0.1")␊ 16 | {␊ 17 | query: Query␊ 18 | }␊ 19 | ␊ 20 | directive @core(feature: String!) repeatable on SCHEMA␊ 21 | ␊ 22 | directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION␊ 23 | ␊ 24 | directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE␊ 25 | ␊ 26 | directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE␊ 27 | ␊ 28 | directive @join__graph(name: String!, url: String!) on ENUM_VALUE␊ 29 | ␊ 30 | scalar join__FieldSet␊ 31 | ␊ 32 | enum join__Graph {␊ 33 | SUPERGRAPH_SVC_BAR @join__graph(name: "supergraph_svc_bar" url: "http://supergraph_svc_bar:3001/api/graphql")␊ 34 | SUPERGRAPH_SVC_FOO @join__graph(name: "supergraph_svc_foo" url: "http://supergraph_svc_foo:3000/api/graphql")␊ 35 | }␊ 36 | ␊ 37 | type Query {␊ 38 | world: String @join__field(graph: SUPERGRAPH_SVC_BAR)␊ 39 | hello: String @join__field(graph: SUPERGRAPH_SVC_FOO)␊ 40 | }␊ 41 | `, 42 | } 43 | -------------------------------------------------------------------------------- /src/registry/federation/supergraph-schema.test.ts.md.3648652584: -------------------------------------------------------------------------------- 1 | # Snapshot report for `src/registry/federation/supergraph-schema.test.ts` 2 | 3 | The actual snapshot is saved in `supergraph-schema.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## Should return supergraph of two services 8 | 9 | > supergraph composition 10 | 11 | { 12 | compositionId: '9602cc2610732cd84a86d2d463e036456753fe78ef49b573ffb29b758f959de9', 13 | supergraphSdl: `schema␊ 14 | @core(feature: "https://specs.apollo.dev/core/v0.1"),␊ 15 | @core(feature: "https://specs.apollo.dev/join/v0.1")␊ 16 | {␊ 17 | query: Query␊ 18 | }␊ 19 | ␊ 20 | directive @core(feature: String!) repeatable on SCHEMA␊ 21 | ␊ 22 | directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION␊ 23 | ␊ 24 | directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE␊ 25 | ␊ 26 | directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE␊ 27 | ␊ 28 | directive @join__graph(name: String!, url: String!) on ENUM_VALUE␊ 29 | ␊ 30 | scalar join__FieldSet␊ 31 | ␊ 32 | enum join__Graph {␊ 33 | SUPERGRAPH_SVC_BAR @join__graph(name: "supergraph_svc_bar" url: "http://supergraph_svc_bar:3001/api/graphql")␊ 34 | SUPERGRAPH_SVC_FOO @join__graph(name: "supergraph_svc_foo" url: "http://supergraph_svc_foo:3000/api/graphql")␊ 35 | }␊ 36 | ␊ 37 | type Query {␊ 38 | hello: String @join__field(graph: SUPERGRAPH_SVC_FOO)␊ 39 | world: String @join__field(graph: SUPERGRAPH_SVC_BAR)␊ 40 | }␊ 41 | `, 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | - 'examples/**' 11 | - 'insomnia.json' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | - 'examples/**' 17 | - 'insomnia.json' 18 | 19 | jobs: 20 | test: 21 | name: Node.js v${{ matrix.nodejs }} 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | nodejs: [14] 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.nodejs }} 31 | 32 | - name: Start services 33 | run: | 34 | docker-compose up -d 35 | ./scripts/wait-for-healthy-container.sh postgres 30 36 | 37 | - name: (env) cache 38 | uses: actions/cache@v2 39 | with: 40 | # npm cache files are stored in `~/.npm` on Linux/macOS 41 | path: ~/.npm 42 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 43 | restore-keys: | 44 | ${{ runner.os }}-build-${{ env.cache-name }}- 45 | ${{ runner.os }}-build- 46 | ${{ runner.os }}- 47 | 48 | - name: Install 49 | run: npm ci --prefer-offline --no-audit 50 | 51 | - name: Run Tests 52 | run: npm run cov 53 | 54 | - name: Coveralls 55 | uses: coverallsapp/github-action@master 56 | with: 57 | github-token: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Compiles 60 | run: npm run build 61 | 62 | - name: Clean services 63 | if: always() 64 | run: docker-compose down 65 | -------------------------------------------------------------------------------- /scripts/wait-for-healthy-container.sh: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/jordyv/wait-for-healthy-container/blob/master/wait-for-healthy-container.sh 2 | 3 | #!/usr/bin/env bash 4 | container_name=$1 5 | shift 6 | timeout=$1 7 | 8 | default_timeout=120 9 | 10 | if [ -z ${timeout} ]; then 11 | timeout=${default_timeout} 12 | fi 13 | 14 | RETURN_HEALTHY=0 15 | RETURN_STARTING=1 16 | RETURN_UNHEALTHY=2 17 | RETURN_UNKNOWN=3 18 | RETURN_ERROR=99 19 | 20 | function usage() { 21 | echo " 22 | Usage: wait-for-healthy-container.sh [timeout] 23 | " 24 | return 25 | } 26 | 27 | function get_health_state { 28 | state=$(docker inspect -f '{{ .State.Health.Status }}' ${container_name}) 29 | return_code=$? 30 | if [ ! ${return_code} -eq 0 ]; then 31 | exit ${RETURN_ERROR} 32 | fi 33 | if [[ "${state}" == "healthy" ]]; then 34 | return ${RETURN_HEALTHY} 35 | elif [[ "${state}" == "unhealthy" ]]; then 36 | return ${RETURN_UNHEALTHY} 37 | elif [[ "${state}" == "starting" ]]; then 38 | return ${RETURN_STARTING} 39 | else 40 | return ${RETURN_UNKNOWN} 41 | fi 42 | } 43 | 44 | function wait_for() { 45 | echo "Wait for container '$container_name' to be healthy for max $timeout seconds..." 46 | for i in `seq ${timeout}`; do 47 | get_health_state 48 | state=$? 49 | if [ ${state} -eq 0 ]; then 50 | echo "Container is healthy after ${i} seconds." 51 | exit 0 52 | fi 53 | sleep 1 54 | done 55 | 56 | echo "Timeout exceeded. Health status returned: $(docker inspect -f '{{ .State.Health.Status }}' ${container_name})" 57 | exit 1 58 | } 59 | 60 | if [ -z ${container_name} ]; then 61 | usage 62 | exit 1 63 | else 64 | wait_for 65 | fi -------------------------------------------------------------------------------- /src/build-server.ts: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify' 2 | import registryPlugin from './registry' 3 | import health from './core/health' 4 | import knexPlugin from './core/knex-plugin' 5 | import validatorPlugin from './core/validator-plugin' 6 | import { ErrorResponse } from './core/types' 7 | 8 | export interface buildOptions { 9 | logger?: boolean 10 | databaseConnectionUrl: string 11 | databaseSchema?: string 12 | basicAuth?: string 13 | prettyPrint?: boolean 14 | jwtSecret?: string 15 | } 16 | 17 | export default function build(opts: buildOptions) { 18 | const fastify = Fastify({ 19 | logger: opts.logger 20 | ? { 21 | prettyPrint: opts.prettyPrint, 22 | } 23 | : undefined, 24 | }) 25 | 26 | // Custom ajv validator 27 | fastify.register(validatorPlugin) 28 | 29 | // Database client 30 | fastify.register(knexPlugin, { 31 | databaseConnectionUrl: opts.databaseConnectionUrl, 32 | databaseSchema: opts.databaseSchema, 33 | }) 34 | 35 | // Registry 36 | fastify.register(registryPlugin, { 37 | basicAuth: opts.basicAuth, 38 | jwtSecret: opts.jwtSecret, 39 | }) 40 | 41 | // Health check 42 | fastify.register(health) 43 | 44 | fastify.setErrorHandler(function (err, request, reply) { 45 | this.log.error(err) 46 | if (err.validation) { 47 | reply.code(400) 48 | reply.send({ 49 | success: false, 50 | error: err.message, 51 | }) 52 | return 53 | } 54 | const result: ErrorResponse = { 55 | success: false, 56 | } 57 | // only expose error informations when it was intented for 58 | if (err.name === 'FastifyError') { 59 | result.error = err.message 60 | } 61 | 62 | reply.code(err.statusCode || 500).send(result) 63 | }) 64 | 65 | return fastify 66 | } 67 | -------------------------------------------------------------------------------- /examples/apollo-federation/accounts.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | me: User 9 | } 10 | type User @key(fields: "id") { 11 | id: ID! 12 | name: String 13 | username: String 14 | } 15 | ` 16 | 17 | async function main() { 18 | // Push schema to registry 19 | await post(`http://localhost:3000/schema/push`, { 20 | body: { 21 | typeDefs: typeDefs, 22 | graphName: 'my_graph', 23 | serviceName: 'accounts', 24 | routingUrl: 'http://localhost:4001/graphql', 25 | }, 26 | }) 27 | startServer() 28 | } 29 | 30 | function startServer() { 31 | const resolvers = { 32 | Query: { 33 | me() { 34 | return users[0] 35 | }, 36 | }, 37 | User: { 38 | __resolveReference(object) { 39 | return users.find((user) => user.id === object.id) 40 | }, 41 | }, 42 | } 43 | 44 | const server = new ApolloServer({ 45 | schema: buildFederatedSchema([ 46 | { 47 | typeDefs: parse(typeDefs), 48 | resolvers, 49 | }, 50 | ]), 51 | }) 52 | 53 | server.listen({ port: 4001 }).then(({ url }) => { 54 | console.log(`🚀 Server ready at ${url}`) 55 | }) 56 | 57 | const users = [ 58 | { 59 | id: '1', 60 | name: 'Ada Lovelace', 61 | birthDate: '1815-12-10', 62 | username: '@ada', 63 | }, 64 | { 65 | id: '2', 66 | name: 'Alan Turing', 67 | birthDate: '1912-06-23', 68 | username: '@complete', 69 | }, 70 | ] 71 | } 72 | 73 | main().catch((err) => { 74 | console.error(err) 75 | process.exit(1) 76 | }) 77 | -------------------------------------------------------------------------------- /examples/mercurius-federation/accounts.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | me: User 9 | } 10 | type User @key(fields: "id") { 11 | id: ID! 12 | name: String 13 | username: String 14 | } 15 | ` 16 | 17 | async function main() { 18 | // Push schema to registry 19 | await post(`http://localhost:3000/schema/push`, { 20 | body: { 21 | typeDefs: typeDefs, 22 | graphName: 'my_graph', 23 | serviceName: 'accounts', 24 | routingUrl: 'http://localhost:4001/graphql', 25 | }, 26 | }) 27 | startServer() 28 | } 29 | 30 | function startServer() { 31 | const resolvers = { 32 | Query: { 33 | me() { 34 | return users[0] 35 | }, 36 | }, 37 | User: { 38 | __resolveReference(object) { 39 | return users.find((user) => user.id === object.id) 40 | }, 41 | }, 42 | } 43 | 44 | const server = new ApolloServer({ 45 | schema: buildFederatedSchema([ 46 | { 47 | typeDefs: parse(typeDefs), 48 | resolvers, 49 | }, 50 | ]), 51 | }) 52 | 53 | server.listen({ port: 4001 }).then(({ url }) => { 54 | console.log(`🚀 Server ready at ${url}`) 55 | }) 56 | 57 | const users = [ 58 | { 59 | id: '1', 60 | name: 'Ada Lovelace', 61 | birthDate: '1815-12-10', 62 | username: '@ada', 63 | }, 64 | { 65 | id: '2', 66 | name: 'Alan Turing', 67 | birthDate: '1912-06-23', 68 | username: '@complete', 69 | }, 70 | ] 71 | } 72 | 73 | main().catch((err) => { 74 | console.error(err) 75 | process.exit(1) 76 | }) 77 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/accounts.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | me: User 9 | } 10 | type User @key(fields: "id") { 11 | id: ID! 12 | name: String 13 | username: String 14 | } 15 | ` 16 | 17 | async function main() { 18 | // Push schema to registry 19 | await post(`http://localhost:3000/schema/push`, { 20 | body: { 21 | typeDefs: typeDefs, 22 | graphName: 'my_graph', 23 | serviceName: 'accounts', 24 | routingUrl: 'http://localhost:4001/graphql', 25 | }, 26 | }) 27 | startServer() 28 | } 29 | 30 | function startServer() { 31 | const resolvers = { 32 | Query: { 33 | me() { 34 | return users[0] 35 | }, 36 | }, 37 | User: { 38 | __resolveReference(object) { 39 | return users.find((user) => user.id === object.id) 40 | }, 41 | }, 42 | } 43 | 44 | const server = new ApolloServer({ 45 | schema: buildFederatedSchema([ 46 | { 47 | typeDefs: parse(typeDefs), 48 | resolvers, 49 | }, 50 | ]), 51 | }) 52 | 53 | server.listen({ port: 4001 }).then(({ url }) => { 54 | console.log(`🚀 Server ready at ${url}`) 55 | }) 56 | 57 | const users = [ 58 | { 59 | id: '1', 60 | name: 'Ada Lovelace', 61 | birthDate: '1815-12-10', 62 | username: '@ada', 63 | }, 64 | { 65 | id: '2', 66 | name: 'Alan Turing', 67 | birthDate: '1912-06-23', 68 | username: '@complete', 69 | }, 70 | ] 71 | } 72 | 73 | main().catch((err) => { 74 | console.error(err) 75 | process.exit(1) 76 | }) 77 | -------------------------------------------------------------------------------- /src/registry/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | import basicAuth from '../core/basic-auth' 3 | import garbageCollect from './maintanance/garbage-collect' 4 | import composeSchema from './federation/compose-schema' 5 | import composeSchemaVersions from './federation/compose-schema-versions' 6 | import schemaCheck from './schema-validation/schema-check' 7 | import schemaValidation from './schema-validation/schema-validation' 8 | import listGraphs from './federation/list-graphs' 9 | import registerSchema from './federation/register-schema' 10 | import deactivateSchema from './federation/deactivate-schema' 11 | import jwtAuth from '../core/jwt-auth' 12 | import documentValidation from './document-validation/document-validation' 13 | import supergraphSchema from './federation/supergraph-schema' 14 | import schemaCoverage from './schema-validation/schema-coverage' 15 | export interface registryOptions { 16 | basicAuth?: string 17 | jwtSecret?: string 18 | } 19 | 20 | export default async function Registry(fastify: FastifyInstance, opts: registryOptions) { 21 | // Authentication, only valid in this register scope 22 | if (opts.basicAuth) { 23 | fastify.register(basicAuth, { 24 | basicAuthSecrets: opts.basicAuth, 25 | }) 26 | } else if (opts.jwtSecret) { 27 | fastify.register(jwtAuth, { 28 | secret: opts.jwtSecret, 29 | }) 30 | } 31 | 32 | fastify.after(() => { 33 | if (opts.basicAuth) { 34 | fastify.addHook('onRequest', fastify.basicAuth) 35 | } 36 | }) 37 | 38 | documentValidation(fastify) 39 | listGraphs(fastify) 40 | registerSchema(fastify) 41 | schemaCoverage(fastify) 42 | garbageCollect(fastify) 43 | supergraphSchema(fastify) 44 | composeSchema(fastify) 45 | composeSchemaVersions(fastify) 46 | deactivateSchema(fastify) 47 | schemaCheck(fastify) 48 | schemaValidation(fastify) 49 | } 50 | -------------------------------------------------------------------------------- /benchmark/composed-schema.js: -------------------------------------------------------------------------------- 1 | import http from 'k6/http' 2 | import { check, fail } from 'k6' 3 | 4 | export let options = { 5 | vus: 2, 6 | duration: '10s', 7 | thresholds: { 8 | http_req_duration: ['p(99)<500'], // 99% of requests must complete below 0.5s 9 | }, 10 | } 11 | const BASE_URL = `${__ENV.URL}` 12 | 13 | const requestOptions = { 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | } 18 | 19 | export function setup() { 20 | let data = { 21 | typeDefs: 'type Query { hello: String }', 22 | version: '1', 23 | routingUrl: `http://foo:3000/api/graphql`, 24 | graphName: 'my_graph', 25 | serviceName: 'foo', 26 | } 27 | let res = http.post(`${BASE_URL}/schema/push`, JSON.stringify(data), requestOptions) 28 | 29 | if ( 30 | !check(res, { 31 | 'returns success': (resp) => resp.json('success'), 32 | }) 33 | ) { 34 | fail('could not push schema') 35 | } 36 | 37 | data = { 38 | typeDefs: 'type Query { world: String }', 39 | version: '1', 40 | routingUrl: 'http://bar:3001/api/graphql', 41 | graphName: 'my_graph', 42 | serviceName: 'bar', 43 | } 44 | res = http.post(`${BASE_URL}/schema/push`, JSON.stringify(data), requestOptions) 45 | 46 | if ( 47 | !check(res, { 48 | 'returns success': (resp) => resp.json('success'), 49 | }) 50 | ) { 51 | fail('could not push schema') 52 | } 53 | } 54 | 55 | export default () => { 56 | const data = { 57 | graphName: 'my_graph', 58 | services: [ 59 | { name: 'foo', version: '1' }, 60 | { name: 'bar', version: '1' }, 61 | ], 62 | } 63 | const res = http.post(`${BASE_URL}/schema/compose`, JSON.stringify(data), requestOptions) 64 | 65 | check(res, { 66 | 'returns success': (resp) => resp.json('success'), 67 | 'returns 2 schemas': (resp) => resp.json('data').length === 2, 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /src/registry/federation/list-graphs.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should return all registered graphs', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'POST', 18 | url: '/schema/push', 19 | payload: { 20 | typeDefs: /* GraphQL */ ` 21 | type Query { 22 | hello: String 23 | } 24 | `, 25 | version: '1', 26 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 27 | serviceName: `${t.context.testPrefix}_foo`, 28 | graphName: `${t.context.graphName}`, 29 | }, 30 | }) 31 | t.is(res.statusCode, 200) 32 | res = await app.inject({ 33 | method: 'POST', 34 | url: '/schema/push', 35 | payload: { 36 | typeDefs: /* GraphQL */ ` 37 | type Query { 38 | world: String 39 | } 40 | `, 41 | version: '1', 42 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 43 | serviceName: `${t.context.testPrefix}_bar`, 44 | graphName: `${t.context.graphName}_2`, 45 | }, 46 | }) 47 | t.is(res.statusCode, 200) 48 | 49 | res = await app.inject({ 50 | method: 'GET', 51 | url: '/graphs', 52 | }) 53 | 54 | t.is(res.statusCode, 200) 55 | 56 | t.deepEqual( 57 | res.json(), 58 | { 59 | success: true, 60 | data: [`${t.context.graphName}`, `${t.context.graphName}_2`], 61 | }, 62 | 'response payload match', 63 | ) 64 | }) 65 | -------------------------------------------------------------------------------- /src/registry/federation/supergraph-schema.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should return supergraph of two services', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'POST', 18 | url: '/schema/push', 19 | payload: { 20 | typeDefs: /* GraphQL */ ` 21 | type Query { 22 | hello: String 23 | } 24 | `, 25 | version: '1', 26 | routingUrl: `http://supergraph_svc_foo:3000/api/graphql`, 27 | serviceName: `supergraph_svc_foo`, 28 | graphName: `${t.context.graphName}`, 29 | }, 30 | }) 31 | t.is(res.statusCode, 200) 32 | res = await app.inject({ 33 | method: 'POST', 34 | url: '/schema/push', 35 | payload: { 36 | typeDefs: /* GraphQL */ ` 37 | type Query { 38 | world: String 39 | } 40 | `, 41 | version: '2', 42 | routingUrl: 'http://supergraph_svc_bar:3001/api/graphql', 43 | serviceName: `supergraph_svc_bar`, 44 | graphName: `${t.context.graphName}`, 45 | }, 46 | }) 47 | t.is(res.statusCode, 200) 48 | 49 | res = await app.inject({ 50 | method: 'POST', 51 | url: '/schema/supergraph', 52 | payload: { 53 | graphName: `${t.context.graphName}`, 54 | }, 55 | }) 56 | 57 | t.is(res.statusCode, 200) 58 | 59 | const response = res.json() 60 | 61 | t.true(response.success) 62 | 63 | t.snapshot(response.data, 'supergraph composition') 64 | }) 65 | -------------------------------------------------------------------------------- /examples/apollo-federation/inventory.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Product @key(fields: "upc") { 8 | upc: String! @external 9 | weight: Int @external 10 | price: Int @external 11 | inStock: Boolean 12 | shippingEstimate: Int @requires(fields: "price weight") 13 | } 14 | ` 15 | 16 | async function main() { 17 | // Push schema to registry 18 | await post(`http://localhost:3000/schema/push`, { 19 | body: { 20 | typeDefs: typeDefs, 21 | graphName: 'my_graph', 22 | serviceName: 'inventory', 23 | routingUrl: 'http://localhost:4004/graphql', 24 | }, 25 | }) 26 | startServer() 27 | } 28 | 29 | function startServer() { 30 | const resolvers = { 31 | Product: { 32 | __resolveReference(object) { 33 | return { 34 | ...object, 35 | ...inventory.find((product) => product.upc === object.upc), 36 | } 37 | }, 38 | shippingEstimate(object) { 39 | // free for expensive items 40 | if (object.price > 1000) return 0 41 | // estimate is based on weight 42 | return object.weight * 0.5 43 | }, 44 | }, 45 | } 46 | 47 | const server = new ApolloServer({ 48 | schema: buildFederatedSchema([ 49 | { 50 | typeDefs: parse(typeDefs), 51 | resolvers, 52 | }, 53 | ]), 54 | }) 55 | 56 | server.listen({ port: 4004 }).then(({ url }) => { 57 | console.log(`🚀 Server ready at ${url}`) 58 | }) 59 | 60 | const inventory = [ 61 | { upc: '1', inStock: true }, 62 | { upc: '2', inStock: false }, 63 | { upc: '3', inStock: true }, 64 | ] 65 | } 66 | 67 | main().catch((err) => { 68 | console.error(err) 69 | process.exit(1) 70 | }) 71 | -------------------------------------------------------------------------------- /examples/mercurius-federation/inventory.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Product @key(fields: "upc") { 8 | upc: String! @external 9 | weight: Int @external 10 | price: Int @external 11 | inStock: Boolean 12 | shippingEstimate: Int @requires(fields: "price weight") 13 | } 14 | ` 15 | 16 | async function main() { 17 | // Push schema to registry 18 | await post(`http://localhost:3000/schema/push`, { 19 | body: { 20 | typeDefs: typeDefs, 21 | graphName: 'my_graph', 22 | serviceName: 'inventory', 23 | routingUrl: 'http://localhost:4004/graphql', 24 | }, 25 | }) 26 | startServer() 27 | } 28 | 29 | function startServer() { 30 | const resolvers = { 31 | Product: { 32 | __resolveReference(object) { 33 | return { 34 | ...object, 35 | ...inventory.find((product) => product.upc === object.upc), 36 | } 37 | }, 38 | shippingEstimate(object) { 39 | // free for expensive items 40 | if (object.price > 1000) return 0 41 | // estimate is based on weight 42 | return object.weight * 0.5 43 | }, 44 | }, 45 | } 46 | 47 | const server = new ApolloServer({ 48 | schema: buildFederatedSchema([ 49 | { 50 | typeDefs: parse(typeDefs), 51 | resolvers, 52 | }, 53 | ]), 54 | }) 55 | 56 | server.listen({ port: 4004 }).then(({ url }) => { 57 | console.log(`🚀 Server ready at ${url}`) 58 | }) 59 | 60 | const inventory = [ 61 | { upc: '1', inStock: true }, 62 | { upc: '2', inStock: false }, 63 | { upc: '3', inStock: true }, 64 | ] 65 | } 66 | 67 | main().catch((err) => { 68 | console.error(err) 69 | process.exit(1) 70 | }) 71 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/inventory.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Product @key(fields: "upc") { 8 | upc: String! @external 9 | weight: Int @external 10 | price: Int @external 11 | inStock: Boolean 12 | shippingEstimate: Int @requires(fields: "price weight") 13 | } 14 | ` 15 | 16 | async function main() { 17 | // Push schema to registry 18 | await post(`http://localhost:3000/schema/push`, { 19 | body: { 20 | typeDefs: typeDefs, 21 | graphName: 'my_graph', 22 | serviceName: 'inventory', 23 | routingUrl: 'http://localhost:4004/graphql', 24 | }, 25 | }) 26 | startServer() 27 | } 28 | 29 | function startServer() { 30 | const resolvers = { 31 | Product: { 32 | __resolveReference(object) { 33 | return { 34 | ...object, 35 | ...inventory.find((product) => product.upc === object.upc), 36 | } 37 | }, 38 | shippingEstimate(object) { 39 | // free for expensive items 40 | if (object.price > 1000) return 0 41 | // estimate is based on weight 42 | return object.weight * 0.5 43 | }, 44 | }, 45 | } 46 | 47 | const server = new ApolloServer({ 48 | schema: buildFederatedSchema([ 49 | { 50 | typeDefs: parse(typeDefs), 51 | resolvers, 52 | }, 53 | ]), 54 | }) 55 | 56 | server.listen({ port: 4004 }).then(({ url }) => { 57 | console.log(`🚀 Server ready at ${url}`) 58 | }) 59 | 60 | const inventory = [ 61 | { upc: '1', inStock: true }, 62 | { upc: '2', inStock: false }, 63 | { upc: '3', inStock: true }, 64 | ] 65 | } 66 | 67 | main().catch((err) => { 68 | console.error(err) 69 | process.exit(1) 70 | }) 71 | -------------------------------------------------------------------------------- /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: BENCH 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | - 'examples/**' 11 | - 'insomnia.json' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | - 'examples/**' 17 | - 'insomnia.json' 18 | 19 | jobs: 20 | test: 21 | name: Node.js v${{ matrix.nodejs }} 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | nodejs: [14] 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.nodejs }} 31 | 32 | - name: (env) cache 33 | uses: actions/cache@v2 34 | with: 35 | # npm cache files are stored in `~/.npm` on Linux/macOS 36 | path: ~/.npm 37 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-build-${{ env.cache-name }}- 40 | ${{ runner.os }}-build- 41 | ${{ runner.os }}- 42 | 43 | - name: Start services 44 | run: | 45 | docker-compose up -d postgres 46 | ./scripts/wait-for-healthy-container.sh postgres 30 47 | 48 | - name: Install 49 | run: npm ci --prefer-offline --no-audit 50 | 51 | - name: Create db schema 52 | run: | 53 | docker exec -t postgres createdb -U postgres graphql-registry 54 | DATABASE_URL="postgresql://postgres:changeme@localhost:5440/graphql-registry?schema=public" npm run migrate:up 55 | 56 | - name: Build app image 57 | run: docker-compose up -d app 58 | 59 | - name: Bench 60 | run: | 61 | sleep 5 62 | docker-compose run k6 run /benchmark/composed-schema.js 63 | 64 | - name: Clean services 65 | if: always() 66 | run: docker-compose down 67 | -------------------------------------------------------------------------------- /examples/apollo-federation/products.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | topProducts(first: Int = 5): [Product] 9 | } 10 | type Product @key(fields: "upc") { 11 | upc: String! 12 | name: String 13 | price: Int 14 | weight: Int 15 | } 16 | ` 17 | 18 | async function main() { 19 | // Push schema to registry 20 | await post(`http://localhost:3000/schema/push`, { 21 | body: { 22 | typeDefs: typeDefs, 23 | graphName: 'my_graph', 24 | serviceName: 'products', 25 | routingUrl: 'http://localhost:4003/graphql', 26 | }, 27 | }) 28 | startServer() 29 | } 30 | 31 | function startServer() { 32 | const resolvers = { 33 | Product: { 34 | __resolveReference(object) { 35 | return products.find((product) => product.upc === object.upc) 36 | }, 37 | }, 38 | Query: { 39 | topProducts(_, args) { 40 | return products.slice(0, args.first) 41 | }, 42 | }, 43 | } 44 | 45 | const server = new ApolloServer({ 46 | schema: buildFederatedSchema([ 47 | { 48 | typeDefs: parse(typeDefs), 49 | resolvers, 50 | }, 51 | ]), 52 | }) 53 | 54 | server.listen({ port: 4003 }).then(({ url }) => { 55 | console.log(`🚀 Server ready at ${url}`) 56 | }) 57 | 58 | const products = [ 59 | { 60 | upc: '1', 61 | name: 'Table', 62 | price: 899, 63 | weight: 100, 64 | }, 65 | { 66 | upc: '2', 67 | name: 'Couch', 68 | price: 1299, 69 | weight: 1000, 70 | }, 71 | { 72 | upc: '3', 73 | name: 'Chair', 74 | price: 54, 75 | weight: 50, 76 | }, 77 | ] 78 | } 79 | 80 | main().catch((err) => { 81 | console.error(err) 82 | process.exit(1) 83 | }) 84 | -------------------------------------------------------------------------------- /examples/mercurius-federation/products.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | topProducts(first: Int = 5): [Product] 9 | } 10 | type Product @key(fields: "upc") { 11 | upc: String! 12 | name: String 13 | price: Int 14 | weight: Int 15 | } 16 | ` 17 | 18 | async function main() { 19 | // Push schema to registry 20 | await post(`http://localhost:3000/schema/push`, { 21 | body: { 22 | typeDefs: typeDefs, 23 | graphName: 'my_graph', 24 | serviceName: 'products', 25 | routingUrl: 'http://localhost:4003/graphql', 26 | }, 27 | }) 28 | startServer() 29 | } 30 | 31 | function startServer() { 32 | const resolvers = { 33 | Product: { 34 | __resolveReference(object) { 35 | return products.find((product) => product.upc === object.upc) 36 | }, 37 | }, 38 | Query: { 39 | topProducts(_, args) { 40 | return products.slice(0, args.first) 41 | }, 42 | }, 43 | } 44 | 45 | const server = new ApolloServer({ 46 | schema: buildFederatedSchema([ 47 | { 48 | typeDefs: parse(typeDefs), 49 | resolvers, 50 | }, 51 | ]), 52 | }) 53 | 54 | server.listen({ port: 4003 }).then(({ url }) => { 55 | console.log(`🚀 Server ready at ${url}`) 56 | }) 57 | 58 | const products = [ 59 | { 60 | upc: '1', 61 | name: 'Table', 62 | price: 899, 63 | weight: 100, 64 | }, 65 | { 66 | upc: '2', 67 | name: 'Couch', 68 | price: 1299, 69 | weight: 1000, 70 | }, 71 | { 72 | upc: '3', 73 | name: 'Chair', 74 | price: 54, 75 | weight: 50, 76 | }, 77 | ] 78 | } 79 | 80 | main().catch((err) => { 81 | console.error(err) 82 | process.exit(1) 83 | }) 84 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/products.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | extend type Query { 8 | topProducts(first: Int = 5): [Product] 9 | } 10 | type Product @key(fields: "upc") { 11 | upc: String! 12 | name: String 13 | price: Int 14 | weight: Int 15 | } 16 | ` 17 | 18 | async function main() { 19 | // Push schema to registry 20 | await post(`http://localhost:3000/schema/push`, { 21 | body: { 22 | typeDefs: typeDefs, 23 | graphName: 'my_graph', 24 | serviceName: 'products', 25 | routingUrl: 'http://localhost:4003/graphql', 26 | }, 27 | }) 28 | startServer() 29 | } 30 | 31 | function startServer() { 32 | const resolvers = { 33 | Product: { 34 | __resolveReference(object) { 35 | return products.find((product) => product.upc === object.upc) 36 | }, 37 | }, 38 | Query: { 39 | topProducts(_, args) { 40 | return products.slice(0, args.first) 41 | }, 42 | }, 43 | } 44 | 45 | const server = new ApolloServer({ 46 | schema: buildFederatedSchema([ 47 | { 48 | typeDefs: parse(typeDefs), 49 | resolvers, 50 | }, 51 | ]), 52 | }) 53 | 54 | server.listen({ port: 4003 }).then(({ url }) => { 55 | console.log(`🚀 Server ready at ${url}`) 56 | }) 57 | 58 | const products = [ 59 | { 60 | upc: '1', 61 | name: 'Table', 62 | price: 899, 63 | weight: 100, 64 | }, 65 | { 66 | upc: '2', 67 | name: 'Couch', 68 | price: 1299, 69 | weight: 1000, 70 | }, 71 | { 72 | upc: '3', 73 | name: 'Chair', 74 | price: 54, 75 | weight: 50, 76 | }, 77 | ] 78 | } 79 | 80 | main().catch((err) => { 81 | console.error(err) 82 | process.exit(1) 83 | }) 84 | -------------------------------------------------------------------------------- /examples/apollo-federation/gateway.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { ApolloGateway } = require('@apollo/gateway') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | async function fetchServices() { 7 | const res = await post(`http://localhost:3000/schema/compose`, { 8 | body: { 9 | graphName: 'my_graph', 10 | services: [ 11 | { name: 'accounts', version: 'current' }, 12 | { name: 'inventory', version: 'current' }, 13 | { name: 'products', version: 'current' }, 14 | { name: 'reviews', version: 'current' }, 15 | ], 16 | }, 17 | }) 18 | 19 | return res.data.data.map((svc) => { 20 | return { 21 | name: svc.serviceName, 22 | url: svc.routingUrl, 23 | typeDefs: parse(svc.typeDefs), 24 | } 25 | }) 26 | } 27 | 28 | async function startServer() { 29 | const gateway = new ApolloGateway({ 30 | // fetch for schema updates every 30s 31 | experimental_pollInterval: 30000, 32 | 33 | async experimental_updateServiceDefinitions() { 34 | return { 35 | isNewSchema: true, 36 | serviceDefinitions: await fetchServices(), 37 | } 38 | }, 39 | 40 | // Experimental: Enabling this enables the query plan view in Playground. 41 | __exposeQueryPlanExperimental: false, 42 | }) 43 | 44 | const server = new ApolloServer({ 45 | gateway, 46 | 47 | // Apollo Graph Manager (previously known as Apollo Engine) 48 | // When enabled and an `ENGINE_API_KEY` is set in the environment, 49 | // provides metrics, schema management and trace reporting. 50 | engine: false, 51 | 52 | // Subscriptions are unsupported but planned for a future Gateway version. 53 | subscriptions: false, 54 | }) 55 | 56 | server.listen().then(({ url }) => { 57 | console.log(`🚀 Server ready at ${url}`) 58 | }) 59 | } 60 | 61 | startServer().catch((err) => { 62 | console.error(err) 63 | process.exit(1) 64 | }) 65 | -------------------------------------------------------------------------------- /src/registry/maintanance/garbage-collect.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should keep the most recent 10 schemas of every service in the graph', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | for (let i = 0; i < 15; i++) { 17 | let res = await app.inject({ 18 | method: 'POST', 19 | url: '/schema/push', 20 | payload: { 21 | typeDefs: `type Query { hello${i}: String }`, 22 | version: '1', 23 | routingUrl: `http://${t.context.testPrefix}_bar:3000/api/graphql`, 24 | serviceName: `${t.context.testPrefix}_foo`, 25 | graphName: `${t.context.graphName}`, 26 | }, 27 | }) 28 | 29 | t.is(res.statusCode, 200) 30 | res = await app.inject({ 31 | method: 'POST', 32 | url: '/schema/push', 33 | payload: { 34 | typeDefs: `type Query { world${i}: String }`, 35 | version: '1', 36 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 37 | serviceName: `${t.context.testPrefix}_bar`, 38 | graphName: `${t.context.graphName}`, 39 | }, 40 | }) 41 | t.is(res.statusCode, 200) 42 | } 43 | 44 | let res = await app.inject({ 45 | method: 'POST', 46 | url: '/schema/garbage_collect', 47 | payload: { 48 | numSchemasKeep: 10, 49 | }, 50 | }) 51 | 52 | t.is(res.statusCode, 200) 53 | 54 | t.deepEqual( 55 | res.json(), 56 | { 57 | success: true, 58 | data: { 59 | deletedSchemas: 20, 60 | deletedVersions: 20, 61 | }, 62 | }, 63 | 'response payload match', 64 | ) 65 | }) 66 | -------------------------------------------------------------------------------- /src/registry/maintanance/garbage-collect.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { FastifyInstance, FastifySchema } from 'fastify' 3 | import { SchemaDBModel } from '../../core/models/schemaModel' 4 | import { SchemaTagDBModel } from '../../core/models/schemaTagModel' 5 | 6 | export interface RequestContext { 7 | Body: { 8 | numSchemasKeep: number 9 | } 10 | } 11 | 12 | export const schema: FastifySchema = { 13 | response: { 14 | '2xx': S.object() 15 | .additionalProperties(false) 16 | .required(['success', 'data']) 17 | .prop('success', S.boolean()) 18 | .prop( 19 | 'data', 20 | S.object() 21 | .required(['deletedSchemas', 'deletedVersions']) 22 | .prop('deletedSchemas', S.number()) 23 | .prop('deletedVersions', S.number()), 24 | ), 25 | }, 26 | body: S.object() 27 | .additionalProperties(false) 28 | .required(['numSchemasKeep']) 29 | .prop('numSchemasKeep', S.number().minimum(10).maximum(100)), 30 | } 31 | 32 | export default function garbageCollect(fastify: FastifyInstance) { 33 | fastify.post('/schema/garbage_collect', { schema }, async (req, res) => { 34 | return fastify.knex.transaction(async function (trx) { 35 | const schemasToKeep = await trx(SchemaDBModel.table) 36 | .orderBy(SchemaDBModel.field('updatedAt'), 'desc') 37 | .limit(req.body.numSchemasKeep) 38 | 39 | const deletedSchemaTags = await trx(SchemaTagDBModel.table) 40 | .whereNotIn( 41 | `${SchemaTagDBModel.field('schemaId')}`, 42 | schemasToKeep.map((s) => s.id), 43 | ) 44 | .delete() 45 | 46 | const deletedSchemas = await trx(SchemaDBModel.table) 47 | .whereNotIn( 48 | SchemaDBModel.field('id'), 49 | schemasToKeep.map((s) => s.id), 50 | ) 51 | .delete() 52 | 53 | return { 54 | success: true, 55 | data: { 56 | deletedSchemas: deletedSchemas, 57 | deletedVersions: deletedSchemaTags, 58 | }, 59 | } 60 | }) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /src/core/basic-auth.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from './test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should return 200 because credentials are valid', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | basicAuth: '123', 14 | }) 15 | t.teardown(() => app.close()) 16 | 17 | let res = await app.inject({ 18 | method: 'POST', 19 | url: '/schema/push', 20 | payload: { 21 | typeDefs: `type Query { world: String }`, 22 | version: '2', 23 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 24 | serviceName: `${t.context.testPrefix}_bar`, 25 | graphName: `${t.context.graphName}`, 26 | }, 27 | headers: { 28 | authorization: 'Basic MTIzOjEyMw==', // 123 29 | }, 30 | }) 31 | 32 | t.is(res.statusCode, 200) 33 | }) 34 | 35 | test('Should support multiple secrets comma separated', async (t) => { 36 | const app = build({ 37 | databaseConnectionUrl: t.context.connectionUrl, 38 | basicAuth: '123,456', 39 | }) 40 | t.teardown(() => app.close()) 41 | 42 | let res = await app.inject({ 43 | method: 'POST', 44 | url: '/schema/push', 45 | payload: { 46 | typeDefs: `type Query { world: String }`, 47 | version: '3', 48 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 49 | serviceName: `${t.context.testPrefix}_bar`, 50 | graphName: `${t.context.graphName}`, 51 | }, 52 | headers: { 53 | authorization: 'Basic NDU2OjQ1Ng==', // 456 54 | }, 55 | }) 56 | 57 | t.is(res.statusCode, 200) 58 | }) 59 | 60 | test('Should return 401 because credentials are invalid', async (t) => { 61 | const app = build({ 62 | databaseConnectionUrl: t.context.connectionUrl, 63 | basicAuth: '123', 64 | }) 65 | t.teardown(() => app.close()) 66 | 67 | let res = await app.inject({ 68 | method: 'GET', 69 | url: '/schema/latest', 70 | }) 71 | 72 | t.is(res.statusCode, 401) 73 | }) 74 | -------------------------------------------------------------------------------- /src/core/test-util.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from 'ava' 2 | import execa from 'execa' 3 | import { join } from 'path' 4 | import { uid } from 'uid' 5 | import jwt from 'jsonwebtoken' 6 | import { stripIgnoredCharacters } from 'graphql/utilities' 7 | import { JwtPayload } from './jwt-auth' 8 | 9 | export interface TestContext { 10 | dbName: string 11 | testPrefix: string 12 | graphName: string 13 | bootstrapped: boolean 14 | connectionUrl: string 15 | } 16 | 17 | export function trimDoc(strings: ReadonlyArray, ...values: ReadonlyArray): string { 18 | return stripIgnoredCharacters(strings.join('')) 19 | } 20 | 21 | export function getJwtHeader(payload: JwtPayload) { 22 | const jwtSecret = 'secret' 23 | const jwtToken = jwt.sign(payload, jwtSecret) 24 | return { 25 | authorization: `Bearer ${jwtToken}`, 26 | } 27 | } 28 | 29 | export function createTestContext() { 30 | const knexBinary = join(process.cwd(), 'node_modules', '.bin', 'knex') 31 | 32 | return async (t: ExecutionContext) => { 33 | t.timeout(20000, 'make sure database has bootstrapped') 34 | 35 | t.context = { 36 | bootstrapped: false, 37 | connectionUrl: '', 38 | graphName: '', 39 | testPrefix: '', 40 | dbName: `test_${uid()}`, 41 | } 42 | 43 | t.context.connectionUrl = `postgresql://postgres:changeme@localhost:5440/${t.context.dbName}?schema=public` 44 | 45 | await execa.command(`docker exec -t postgres createdb -U postgres ${t.context.dbName}`, { 46 | shell: true, 47 | }) 48 | 49 | await execa.command( 50 | `${knexBinary} migrate:up --knexfile build/knexfile.js 20210504193054_initial_schema.js`, 51 | { 52 | shell: true, 53 | env: { 54 | DATABASE_URL: t.context.connectionUrl, 55 | }, 56 | }, 57 | ) 58 | 59 | t.context.bootstrapped = true 60 | } 61 | } 62 | 63 | export function createTestPrefix() { 64 | return (t: ExecutionContext) => { 65 | t.context.testPrefix = uid() 66 | t.context.graphName = `${t.context.testPrefix}_graph` 67 | } 68 | } 69 | 70 | export function cleanTest() { 71 | return async (t: ExecutionContext) => { 72 | t.timeout(20000, 'make sure database has deleted') 73 | 74 | if (t.context.bootstrapped) { 75 | await execa.command( 76 | `docker exec -t postgres psql -U postgres -c 'drop database ${t.context.dbName};'`, 77 | { 78 | shell: true, 79 | }, 80 | ) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/registry/federation/deactivate-schema.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should deactivate schema', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'POST', 18 | url: '/schema/push', 19 | payload: { 20 | typeDefs: /* GraphQL */ ` 21 | type Query { 22 | hello: String 23 | } 24 | `, 25 | version: '1', 26 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 27 | serviceName: `${t.context.testPrefix}_foo`, 28 | graphName: `${t.context.graphName}`, 29 | }, 30 | }) 31 | t.is(res.statusCode, 200) 32 | 33 | const schemaId = res.json().data.schemaId 34 | 35 | res = await app.inject({ 36 | method: 'PUT', 37 | url: '/schema/deactivate', 38 | payload: { 39 | schemaId, 40 | graphName: `${t.context.graphName}`, 41 | }, 42 | }) 43 | 44 | t.is(res.statusCode, 200) 45 | 46 | res = await app.inject({ 47 | method: 'POST', 48 | url: '/schema/compose', 49 | payload: { 50 | graphName: `${t.context.graphName}`, 51 | services: [ 52 | { 53 | name: `${t.context.testPrefix}_foo`, 54 | version: '1', 55 | }, 56 | ], 57 | }, 58 | }) 59 | 60 | t.is(res.statusCode, 400) 61 | 62 | t.deepEqual( 63 | res.json(), 64 | { 65 | error: `In graph "${t.context.graphName}", service "${t.context.testPrefix}_foo" has no schema in version "1" registered`, 66 | success: false, 67 | }, 68 | 'response payload match', 69 | ) 70 | }) 71 | 72 | test('Should return 400 when schema does not exist', async (t) => { 73 | const app = build({ 74 | databaseConnectionUrl: t.context.connectionUrl, 75 | }) 76 | t.teardown(() => app.close()) 77 | 78 | let res = await app.inject({ 79 | method: 'PUT', 80 | url: '/schema/deactivate', 81 | payload: { 82 | schemaId: 123, 83 | graphName: `${t.context.graphName}`, 84 | }, 85 | }) 86 | 87 | t.is(res.statusCode, 400) 88 | 89 | t.deepEqual( 90 | res.json(), 91 | { 92 | error: 'Could not find schema with id "123"', 93 | success: false, 94 | }, 95 | 'response payload match', 96 | ) 97 | }) 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "graphql-registry", 4 | "version": "0.1.0", 5 | "main": "build/index.js", 6 | "description": "GraphQL registry", 7 | "license": "AGPL-3.0-or-later", 8 | "scripts": { 9 | "start": "node -r dotenv/config build/index.js", 10 | "build": "tsc", 11 | "dev": "cross-env TS_NODE_TRANSPILE_ONLY=true nodemon --watch 'src/**/*.ts' --exec node --inspect -r ts-node/register -r dotenv/config src/index.ts", 12 | "test": "ava", 13 | "test:watch": "npm run test -- --watch", 14 | "cov": "nyc --reporter=lcov --reporter=text-summary -- npm run test", 15 | "format": "prettier --write '**/*.{js,ts,json,md}'", 16 | "release": "release-it --no-npm --github.release", 17 | "prepare": "husky install && npm run build", 18 | "migrate:make": "knex migrate:make --migrations-directory src/migrations -x ts", 19 | "migrate:rollback": "knex migrate:rollback --knexfile build/knexfile.js", 20 | "migrate:up": "knex migrate:up --knexfile build/knexfile.js", 21 | "migrate:down": "knex migrate:rollback --all --knexfile build/knexfile.js" 22 | }, 23 | "engines": { 24 | "node": ">=14" 25 | }, 26 | "files": [ 27 | "build" 28 | ], 29 | "release-it": { 30 | "git": { 31 | "commitArgs": [ 32 | "--no-verify" 33 | ] 34 | } 35 | }, 36 | "ava": { 37 | "files": [ 38 | "src/**/*.test.ts" 39 | ], 40 | "typescript": { 41 | "rewritePaths": { 42 | "src/": "build/" 43 | }, 44 | "compile": "tsc" 45 | } 46 | }, 47 | "devDependencies": { 48 | "@ava/typescript": "^2.0.0", 49 | "@types/node": "^14.17.0", 50 | "ava": "^3.15.0", 51 | "cross-env": "^7.0.3", 52 | "dotenv": "^10.0.0", 53 | "execa": "^5.1.1", 54 | "husky": "^7.0.1", 55 | "jsonwebtoken": "^8.5.1", 56 | "nodemon": "^2.0.12", 57 | "nyc": "^15.1.0", 58 | "prettier": "^2.3.2", 59 | "release-it": "^14.11.5", 60 | "rimraf": "^3.0.2", 61 | "ts-node": "^10.2.0", 62 | "typescript": "^4.3.5", 63 | "uid": "^2.0.0" 64 | }, 65 | "dependencies": { 66 | "@apollo/federation": "^0.29.0", 67 | "@graphql-inspector/core": "2.6.1", 68 | "ajv": "6.12.6", 69 | "apollo-graphql": "^0.9.3", 70 | "env-schema": "^3.3.0", 71 | "fastify": "^3.20.2", 72 | "fastify-basic-auth": "^2.1.0", 73 | "fastify-error": "^0.3.1", 74 | "fastify-jwt": "^3.0.1", 75 | "fastify-plugin": "^3.0.0", 76 | "fluent-json-schema": "^3.0.1", 77 | "graphql": "^15.5.1", 78 | "knex": "^0.21.21", 79 | "pg": "^8.7.1", 80 | "pino-pretty": "^5.1.3" 81 | }, 82 | "keywords": [ 83 | "graphql", 84 | "registry", 85 | "schema", 86 | "apollo", 87 | "federation" 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /src/core/manager/SchemaManager.ts: -------------------------------------------------------------------------------- 1 | import SchemaRepository from '../repositories/SchemaRepository' 2 | import ServiceRepository from '../repositories/ServiceRepository' 3 | 4 | export interface ServiceSchemaVersionMatch { 5 | name: string 6 | version?: string 7 | } 8 | 9 | export class SchemaManager { 10 | private serviceRepository: ServiceRepository 11 | private schemaRepository: SchemaRepository 12 | 13 | constructor(serviceRepository: ServiceRepository, schemaRepository: SchemaRepository) { 14 | this.serviceRepository = serviceRepository 15 | this.schemaRepository = schemaRepository 16 | } 17 | 18 | async findByServiceVersions(graphName: string, serviceMatches: ServiceSchemaVersionMatch[]) { 19 | const schemas = [] 20 | let error: Error | null = null 21 | 22 | const serviceItems = await this.serviceRepository.findByNames( 23 | { 24 | graphName, 25 | }, 26 | serviceMatches.map((s) => s.name), 27 | ) 28 | 29 | for await (const serviceMatch of serviceMatches) { 30 | const service = serviceItems.find((s) => s.name === serviceMatch.name) 31 | 32 | if (!service) { 33 | error = new Error( 34 | `In graph "${graphName}" service "${serviceMatch.name}" could not be found`, 35 | ) 36 | break 37 | } 38 | 39 | const version = serviceMatch.version 40 | 41 | if (version) { 42 | let schema = await this.schemaRepository.findBySchemaTagVersion({ 43 | graphName, 44 | serviceName: service.name, 45 | version, 46 | }) 47 | 48 | if (!schema) { 49 | error = new Error( 50 | `In graph "${graphName}", service "${service.name}" has no schema in version "${version}" registered`, 51 | ) 52 | break 53 | } 54 | 55 | schemas.push({ 56 | schemaId: schema.id, 57 | serviceName: service.name, 58 | routingUrl: service.routingUrl, 59 | typeDefs: schema.typeDefs, 60 | lastUpdatedAt: schema.updatedAt, 61 | version: version, 62 | }) 63 | } else { 64 | const schema = await this.schemaRepository.findLastUpdated({ 65 | graphName, 66 | serviceName: service.name, 67 | }) 68 | 69 | if (!schema) { 70 | error = new Error( 71 | `In graph "${graphName}", service "${service.name}" has no schema registered`, 72 | ) 73 | break 74 | } 75 | 76 | schemas.push({ 77 | schemaId: schema.id, 78 | serviceName: service.name, 79 | routingUrl: service.routingUrl, 80 | typeDefs: schema.typeDefs, 81 | lastUpdatedAt: schema.updatedAt, 82 | version: schema.version, 83 | }) 84 | } 85 | } 86 | 87 | return { error, schemas } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /examples/apollo-federation/reviews.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | type Review @key(fields: "id") { 8 | id: ID! 9 | body: String 10 | author: User @provides(fields: "username") 11 | product: Product 12 | } 13 | 14 | extend type User @key(fields: "id") { 15 | id: ID! @external 16 | username: String @external 17 | reviews: [Review] 18 | } 19 | 20 | extend type Product @key(fields: "upc") { 21 | upc: String! @external 22 | reviews: [Review] 23 | } 24 | ` 25 | 26 | async function main() { 27 | // Push schema to registry 28 | await post(`http://localhost:3000/schema/push`, { 29 | body: { 30 | typeDefs: typeDefs, 31 | graphName: 'my_graph', 32 | serviceName: 'reviews', 33 | routingUrl: 'http://localhost:4002/graphql', 34 | }, 35 | }) 36 | startServer() 37 | } 38 | 39 | function startServer() { 40 | const resolvers = { 41 | Review: { 42 | author(review) { 43 | return { __typename: 'User', id: review.authorID } 44 | }, 45 | }, 46 | User: { 47 | reviews(user) { 48 | return reviews.filter((review) => review.authorID === user.id) 49 | }, 50 | numberOfReviews(user) { 51 | return reviews.filter((review) => review.authorID === user.id).length 52 | }, 53 | username(user) { 54 | const found = usernames.find((username) => username.id === user.id) 55 | return found ? found.username : null 56 | }, 57 | }, 58 | Product: { 59 | reviews(product) { 60 | return reviews.filter((review) => review.product.upc === product.upc) 61 | }, 62 | }, 63 | } 64 | 65 | const server = new ApolloServer({ 66 | schema: buildFederatedSchema([ 67 | { 68 | typeDefs: parse(typeDefs), 69 | resolvers, 70 | }, 71 | ]), 72 | }) 73 | 74 | server.listen({ port: 4002 }).then(({ url }) => { 75 | console.log(`🚀 Server ready at ${url}`) 76 | }) 77 | 78 | const usernames = [ 79 | { id: '1', username: '@ada' }, 80 | { id: '2', username: '@complete' }, 81 | ] 82 | const reviews = [ 83 | { 84 | id: '1', 85 | authorID: '1', 86 | product: { upc: '1' }, 87 | body: 'Love it!', 88 | }, 89 | { 90 | id: '2', 91 | authorID: '1', 92 | product: { upc: '2' }, 93 | body: 'Too expensive.', 94 | }, 95 | { 96 | id: '3', 97 | authorID: '2', 98 | product: { upc: '3' }, 99 | body: 'Could be better.', 100 | }, 101 | { 102 | id: '4', 103 | authorID: '2', 104 | product: { upc: '1' }, 105 | body: 'Prefer something else.', 106 | }, 107 | ] 108 | } 109 | 110 | main().catch((err) => { 111 | console.error(err) 112 | process.exit(1) 113 | }) 114 | -------------------------------------------------------------------------------- /examples/mercurius-federation/reviews.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | type Review @key(fields: "id") { 8 | id: ID! 9 | body: String 10 | author: User @provides(fields: "username") 11 | product: Product 12 | } 13 | 14 | extend type User @key(fields: "id") { 15 | id: ID! @external 16 | username: String @external 17 | reviews: [Review] 18 | } 19 | 20 | extend type Product @key(fields: "upc") { 21 | upc: String! @external 22 | reviews: [Review] 23 | } 24 | ` 25 | 26 | async function main() { 27 | // Push schema to registry 28 | await post(`http://localhost:3000/schema/push`, { 29 | body: { 30 | typeDefs: typeDefs, 31 | graphName: 'my_graph', 32 | serviceName: 'reviews', 33 | routingUrl: 'http://localhost:4002/graphql', 34 | }, 35 | }) 36 | startServer() 37 | } 38 | 39 | function startServer() { 40 | const resolvers = { 41 | Review: { 42 | author(review) { 43 | return { __typename: 'User', id: review.authorID } 44 | }, 45 | }, 46 | User: { 47 | reviews(user) { 48 | return reviews.filter((review) => review.authorID === user.id) 49 | }, 50 | numberOfReviews(user) { 51 | return reviews.filter((review) => review.authorID === user.id).length 52 | }, 53 | username(user) { 54 | const found = usernames.find((username) => username.id === user.id) 55 | return found ? found.username : null 56 | }, 57 | }, 58 | Product: { 59 | reviews(product) { 60 | return reviews.filter((review) => review.product.upc === product.upc) 61 | }, 62 | }, 63 | } 64 | 65 | const server = new ApolloServer({ 66 | schema: buildFederatedSchema([ 67 | { 68 | typeDefs: parse(typeDefs), 69 | resolvers, 70 | }, 71 | ]), 72 | }) 73 | 74 | server.listen({ port: 4002 }).then(({ url }) => { 75 | console.log(`🚀 Server ready at ${url}`) 76 | }) 77 | 78 | const usernames = [ 79 | { id: '1', username: '@ada' }, 80 | { id: '2', username: '@complete' }, 81 | ] 82 | const reviews = [ 83 | { 84 | id: '1', 85 | authorID: '1', 86 | product: { upc: '1' }, 87 | body: 'Love it!', 88 | }, 89 | { 90 | id: '2', 91 | authorID: '1', 92 | product: { upc: '2' }, 93 | body: 'Too expensive.', 94 | }, 95 | { 96 | id: '3', 97 | authorID: '2', 98 | product: { upc: '3' }, 99 | body: 'Could be better.', 100 | }, 101 | { 102 | id: '4', 103 | authorID: '2', 104 | product: { upc: '1' }, 105 | body: 'Prefer something else.', 106 | }, 107 | ] 108 | } 109 | 110 | main().catch((err) => { 111 | console.error(err) 112 | process.exit(1) 113 | }) 114 | -------------------------------------------------------------------------------- /examples/apollo-managed-federation/reviews.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require('apollo-server') 2 | const { buildFederatedSchema } = require('@apollo/federation') 3 | const { post } = require('httpie') 4 | const { parse } = require('graphql') 5 | 6 | const typeDefs = /* GraphQL */ ` 7 | type Review @key(fields: "id") { 8 | id: ID! 9 | body: String 10 | author: User @provides(fields: "username") 11 | product: Product 12 | } 13 | 14 | extend type User @key(fields: "id") { 15 | id: ID! @external 16 | username: String @external 17 | reviews: [Review] 18 | } 19 | 20 | extend type Product @key(fields: "upc") { 21 | upc: String! @external 22 | reviews: [Review] 23 | } 24 | ` 25 | 26 | async function main() { 27 | // Push schema to registry 28 | await post(`http://localhost:3000/schema/push`, { 29 | body: { 30 | typeDefs: typeDefs, 31 | graphName: 'my_graph', 32 | serviceName: 'reviews', 33 | routingUrl: 'http://localhost:4002/graphql', 34 | }, 35 | }) 36 | startServer() 37 | } 38 | 39 | function startServer() { 40 | const resolvers = { 41 | Review: { 42 | author(review) { 43 | return { __typename: 'User', id: review.authorID } 44 | }, 45 | }, 46 | User: { 47 | reviews(user) { 48 | return reviews.filter((review) => review.authorID === user.id) 49 | }, 50 | numberOfReviews(user) { 51 | return reviews.filter((review) => review.authorID === user.id).length 52 | }, 53 | username(user) { 54 | const found = usernames.find((username) => username.id === user.id) 55 | return found ? found.username : null 56 | }, 57 | }, 58 | Product: { 59 | reviews(product) { 60 | return reviews.filter((review) => review.product.upc === product.upc) 61 | }, 62 | }, 63 | } 64 | 65 | const server = new ApolloServer({ 66 | schema: buildFederatedSchema([ 67 | { 68 | typeDefs: parse(typeDefs), 69 | resolvers, 70 | }, 71 | ]), 72 | }) 73 | 74 | server.listen({ port: 4002 }).then(({ url }) => { 75 | console.log(`🚀 Server ready at ${url}`) 76 | }) 77 | 78 | const usernames = [ 79 | { id: '1', username: '@ada' }, 80 | { id: '2', username: '@complete' }, 81 | ] 82 | const reviews = [ 83 | { 84 | id: '1', 85 | authorID: '1', 86 | product: { upc: '1' }, 87 | body: 'Love it!', 88 | }, 89 | { 90 | id: '2', 91 | authorID: '1', 92 | product: { upc: '2' }, 93 | body: 'Too expensive.', 94 | }, 95 | { 96 | id: '3', 97 | authorID: '2', 98 | product: { upc: '3' }, 99 | body: 'Could be better.', 100 | }, 101 | { 102 | id: '4', 103 | authorID: '2', 104 | product: { upc: '1' }, 105 | body: 'Prefer something else.', 106 | }, 107 | ] 108 | } 109 | 110 | main().catch((err) => { 111 | console.error(err) 112 | process.exit(1) 113 | }) 114 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-validation.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { FastifyInstance, FastifySchema } from 'fastify' 3 | import { 4 | InvalidGraphNameError, 5 | SchemaCompositionError, 6 | SchemaVersionLookupError, 7 | } from '../../core/errors' 8 | import { composeAndValidateSchema } from '../../core/federation' 9 | import { SchemaManager } from '../../core/manager/SchemaManager' 10 | import SchemaRepository from '../../core/repositories/SchemaRepository' 11 | import ServiceRepository from '../../core/repositories/ServiceRepository' 12 | import GraphRepository from '../../core/repositories/GraphRepository' 13 | import { graphName, serviceName, typeDefs } from '../../core/shared-schemas' 14 | 15 | export interface RequestContext { 16 | Body: { 17 | serviceName: string 18 | typeDefs: string 19 | graphName: string 20 | } 21 | } 22 | 23 | export const schema: FastifySchema = { 24 | response: { 25 | '2xx': S.object() 26 | .additionalProperties(false) 27 | .required(['success']) 28 | .prop('success', S.boolean()), 29 | }, 30 | body: S.object() 31 | .additionalProperties(false) 32 | .required(['typeDefs', 'serviceName', 'graphName']) 33 | .prop('graphName', graphName) 34 | .prop('typeDefs', typeDefs) 35 | .prop('serviceName', serviceName), 36 | } 37 | 38 | export default function schemaValidation(fastify: FastifyInstance) { 39 | fastify.post('/schema/validate', { schema }, async (req, res) => { 40 | const graphRepository = new GraphRepository(fastify.knex) 41 | 42 | const graphExists = await graphRepository.exists({ 43 | name: req.body.graphName, 44 | }) 45 | if (!graphExists) { 46 | throw InvalidGraphNameError(req.body.graphName) 47 | } 48 | 49 | const serviceRepository = new ServiceRepository(fastify.knex) 50 | const schemaRepository = new SchemaRepository(fastify.knex) 51 | 52 | const serviceModels = await serviceRepository.findMany({ 53 | graphName: req.body.graphName, 54 | }) 55 | 56 | if (serviceModels.length === 0) { 57 | return res.send({ 58 | success: true, 59 | }) 60 | } 61 | 62 | const allLatestServices = serviceModels.map((s) => ({ 63 | name: s.name, 64 | })) 65 | 66 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 67 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 68 | req.body.graphName, 69 | allLatestServices, 70 | ) 71 | 72 | if (findError) { 73 | throw SchemaVersionLookupError(findError.message) 74 | } 75 | 76 | let serviceSchemas = schemas 77 | .map((s) => ({ 78 | name: s.serviceName, 79 | typeDefs: s.typeDefs, 80 | })) 81 | .filter((schema) => schema.name !== req.body.serviceName) 82 | .concat({ 83 | name: req.body.serviceName, 84 | typeDefs: req.body.typeDefs, 85 | }) 86 | 87 | const updated = composeAndValidateSchema(serviceSchemas) 88 | if (!updated.schema) { 89 | throw SchemaCompositionError(updated.error) 90 | } 91 | if (updated.error) { 92 | throw SchemaCompositionError(updated.error) 93 | } 94 | 95 | return { 96 | success: true, 97 | } 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ag_dubs@cloudflare.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/registry/federation/compose-schema.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { composeAndValidateSchema } from '../../core/federation' 3 | import { SchemaManager } from '../../core/manager/SchemaManager' 4 | import { FastifyInstance, FastifySchema } from 'fastify' 5 | import { 6 | InvalidGraphNameError, 7 | SchemaCompositionError, 8 | SchemaVersionLookupError, 9 | } from '../../core/errors' 10 | import SchemaRepository from '../../core/repositories/SchemaRepository' 11 | import ServiceRepository from '../../core/repositories/ServiceRepository' 12 | import GraphRepository from '../../core/repositories/GraphRepository' 13 | import { 14 | dateTime, 15 | graphName, 16 | routingUrl, 17 | schemaId, 18 | serviceName, 19 | typeDefs, 20 | version, 21 | } from '../../core/shared-schemas' 22 | 23 | export interface RequestContext { 24 | Querystring: { 25 | graphName: string 26 | federation: boolean 27 | } 28 | } 29 | 30 | export const schema: FastifySchema = { 31 | response: { 32 | '2xx': S.object() 33 | .additionalProperties(false) 34 | .required(['success', 'data']) 35 | .prop('success', S.boolean()) 36 | .prop( 37 | 'data', 38 | S.array().items( 39 | S.object() 40 | .required(['version', 'typeDefs', 'serviceName', 'schemaId', 'routingUrl']) 41 | .prop('schemaId', schemaId) 42 | .prop('version', version) 43 | .prop('typeDefs', typeDefs) 44 | .prop('serviceName', serviceName) 45 | .prop('routingUrl', routingUrl) 46 | .prop('lastUpdatedAt', dateTime), 47 | ), 48 | ), 49 | }, 50 | querystring: S.object() 51 | .required(['graphName']) 52 | .additionalProperties(false) 53 | .prop('graphName', graphName), 54 | } 55 | 56 | export default function composeSchema(fastify: FastifyInstance) { 57 | fastify.get('/schema/latest', { schema }, async (req, res) => { 58 | const graphRepository = new GraphRepository(fastify.knex) 59 | 60 | const graphExists = await graphRepository.exists({ 61 | name: req.query.graphName, 62 | }) 63 | if (!graphExists) { 64 | throw InvalidGraphNameError(req.query.graphName) 65 | } 66 | 67 | const serviceRepository = new ServiceRepository(fastify.knex) 68 | const schemaRepository = new SchemaRepository(fastify.knex) 69 | 70 | const serviceModels = await serviceRepository.findMany({ 71 | graphName: req.query.graphName, 72 | }) 73 | if (serviceModels.length === 0) { 74 | return res.send({ 75 | success: true, 76 | data: [], 77 | }) 78 | } 79 | 80 | const allLatestServices = serviceModels.map((s) => ({ 81 | name: s.name, 82 | })) 83 | 84 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 85 | 86 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 87 | req.query.graphName, 88 | allLatestServices, 89 | ) 90 | 91 | if (findError) { 92 | throw SchemaVersionLookupError(findError.message) 93 | } 94 | 95 | if (!schemas.length) { 96 | return res.send({ 97 | success: true, 98 | data: [], 99 | }) 100 | } 101 | 102 | const serviceSchemas = schemas.map((s) => ({ 103 | name: s.serviceName, 104 | url: s.routingUrl, 105 | typeDefs: s.typeDefs, 106 | })) 107 | 108 | const { error: schemaError } = composeAndValidateSchema(serviceSchemas) 109 | 110 | if (schemaError) { 111 | throw SchemaCompositionError(schemaError) 112 | } 113 | 114 | return { 115 | success: true, 116 | data: schemas, 117 | } 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /src/registry/document-validation/document-validation.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { Source } from 'graphql' 3 | import { FastifyInstance, FastifySchema } from 'fastify' 4 | import { validate as validateDocument } from '@graphql-inspector/core' 5 | import { 6 | InvalidDocumentError, 7 | InvalidGraphNameError, 8 | SchemaCompositionError, 9 | SchemaVersionLookupError, 10 | } from '../../core/errors' 11 | import { composeAndValidateSchema } from '../../core/federation' 12 | import { SchemaManager } from '../../core/manager/SchemaManager' 13 | import SchemaRepository from '../../core/repositories/SchemaRepository' 14 | import ServiceRepository from '../../core/repositories/ServiceRepository' 15 | import GraphRepository from '../../core/repositories/GraphRepository' 16 | import { document, graphName } from '../../core/shared-schemas' 17 | 18 | export interface RequestContext { 19 | Body: { 20 | documents: string[] 21 | graphName: string 22 | } 23 | } 24 | 25 | export const schema: FastifySchema = { 26 | response: { 27 | '2xx': S.object() 28 | .additionalProperties(false) 29 | .required(['success']) 30 | .prop('success', S.boolean()), 31 | }, 32 | body: S.object() 33 | .additionalProperties(false) 34 | .required(['documents', 'graphName']) 35 | .prop('graphName', graphName) 36 | .prop('documents', S.array().items(document).minItems(1).maxItems(100)), 37 | } 38 | 39 | export default function documentValidation(fastify: FastifyInstance) { 40 | fastify.post('/document/validate', { schema }, async (req, res) => { 41 | const graphRepository = new GraphRepository(fastify.knex) 42 | 43 | const graphExists = await graphRepository.exists({ 44 | name: req.body.graphName, 45 | }) 46 | if (!graphExists) { 47 | throw InvalidGraphNameError(req.body.graphName) 48 | } 49 | 50 | const serviceRepository = new ServiceRepository(fastify.knex) 51 | const schemaRepository = new SchemaRepository(fastify.knex) 52 | 53 | const serviceModels = await serviceRepository.findMany({ 54 | graphName: req.body.graphName, 55 | }) 56 | 57 | if (serviceModels.length === 0) { 58 | return { 59 | success: true, 60 | } 61 | } 62 | 63 | const allLatestServices = serviceModels.map((s) => ({ 64 | name: s.name, 65 | })) 66 | 67 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 68 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 69 | req.body.graphName, 70 | allLatestServices, 71 | ) 72 | 73 | if (findError) { 74 | throw SchemaVersionLookupError(findError.message) 75 | } 76 | 77 | let serviceSchemas = schemas.map((s) => ({ 78 | name: s.serviceName, 79 | typeDefs: s.typeDefs, 80 | })) 81 | 82 | const updated = composeAndValidateSchema(serviceSchemas) 83 | if (!updated.schema) { 84 | throw SchemaCompositionError(updated.error) 85 | } 86 | if (updated.error) { 87 | throw SchemaCompositionError(updated.error) 88 | } 89 | 90 | let sources: Source[] = [] 91 | try { 92 | sources = req.body.documents.map((document) => new Source(document)) 93 | } catch { 94 | throw InvalidDocumentError() 95 | } 96 | 97 | const invalidDocuments = validateDocument(updated.schema, sources, { 98 | apollo: true, 99 | strictDeprecated: true, 100 | maxDepth: 10, 101 | keepClientFields: true, 102 | }) 103 | 104 | if (invalidDocuments.length > 0) { 105 | res.code(400) 106 | return { 107 | success: false, 108 | error: invalidDocuments, 109 | } 110 | } 111 | 112 | return { 113 | success: true, 114 | } 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/registry/federation/compose-schema-versions.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifySchema } from 'fastify' 2 | import S from 'fluent-json-schema' 3 | import { composeAndValidateSchema } from '../../core/federation' 4 | import { SchemaResponseModel, ServiceVersionMatch, SuccessResponse } from '../../core/types' 5 | import { SchemaManager } from '../../core/manager/SchemaManager' 6 | import { 7 | InvalidGraphNameError, 8 | SchemaCompositionError, 9 | SchemaVersionLookupError, 10 | } from '../../core/errors' 11 | import SchemaRepository from '../../core/repositories/SchemaRepository' 12 | import ServiceRepository from '../../core/repositories/ServiceRepository' 13 | import GraphRepository from '../../core/repositories/GraphRepository' 14 | import { 15 | dateTime, 16 | graphName, 17 | routingUrl, 18 | schemaId, 19 | serviceName, 20 | typeDefs, 21 | version, 22 | } from '../../core/shared-schemas' 23 | 24 | export interface RequestContext { 25 | Body: { 26 | graphName: string 27 | services: ServiceVersionMatch[] 28 | federation: boolean 29 | } 30 | } 31 | 32 | export const schema: FastifySchema = { 33 | response: { 34 | '2xx': S.object() 35 | .additionalProperties(false) 36 | .required(['success', 'data']) 37 | .prop('success', S.boolean()) 38 | .prop( 39 | 'data', 40 | S.array().items( 41 | S.object() 42 | .required(['version', 'typeDefs', 'serviceName', 'schemaId']) 43 | .prop('schemaId', schemaId) 44 | .prop('version', version) 45 | .prop('typeDefs', typeDefs) 46 | .prop('serviceName', serviceName) 47 | .prop('routingUrl', routingUrl) 48 | .prop('lastUpdatedAt', dateTime), 49 | ), 50 | ), 51 | }, 52 | body: S.object() 53 | .additionalProperties(false) 54 | .required(['graphName', 'services']) 55 | .prop('graphName', graphName) 56 | .prop( 57 | 'services', 58 | S.array() 59 | .minItems(1) 60 | .items( 61 | S.object() 62 | .required(['name', 'version']) 63 | .prop('version', version) 64 | .prop('name', serviceName), 65 | ), 66 | ), 67 | } 68 | 69 | export default function composeSchemaVersions(fastify: FastifyInstance) { 70 | fastify.post('/schema/compose', { schema }, async (req, res) => { 71 | const graphRepository = new GraphRepository(fastify.knex) 72 | 73 | const graphExists = await graphRepository.exists({ 74 | name: req.body.graphName, 75 | }) 76 | 77 | if (!graphExists) { 78 | throw InvalidGraphNameError(req.body.graphName) 79 | } 80 | 81 | const serviceRepository = new ServiceRepository(fastify.knex) 82 | const schemaRepository = new SchemaRepository(fastify.knex) 83 | 84 | const allServicesWithVersion: ServiceVersionMatch[] = req.body.services.map((s) => ({ 85 | name: s.name, 86 | version: s.version, 87 | })) 88 | 89 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 90 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 91 | req.body.graphName, 92 | allServicesWithVersion, 93 | ) 94 | 95 | if (findError) { 96 | throw SchemaVersionLookupError(findError.message) 97 | } 98 | 99 | const serviceSchemas = schemas.map((s) => ({ 100 | name: s.serviceName, 101 | typeDefs: s.typeDefs, 102 | })) 103 | 104 | const { error: schemaError } = composeAndValidateSchema(serviceSchemas) 105 | 106 | if (schemaError) { 107 | throw SchemaCompositionError(schemaError) 108 | } 109 | 110 | const responseBody: SuccessResponse = { 111 | success: true, 112 | data: schemas, 113 | } 114 | 115 | return responseBody 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/registry/federation/supergraph-schema.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { composeAndValidateSchema } from '../../core/federation' 3 | import { SchemaManager } from '../../core/manager/SchemaManager' 4 | import { FastifyInstance, FastifySchema } from 'fastify' 5 | import { 6 | InvalidGraphNameError, 7 | SchemaCompositionError, 8 | SchemaVersionLookupError, 9 | SupergraphCompositionError, 10 | } from '../../core/errors' 11 | import SchemaRepository from '../../core/repositories/SchemaRepository' 12 | import ServiceRepository from '../../core/repositories/ServiceRepository' 13 | import GraphRepository from '../../core/repositories/GraphRepository' 14 | import { graphName } from '../../core/shared-schemas' 15 | import { hash } from '../../core/util' 16 | 17 | export interface RequestContext { 18 | Body: { 19 | graphName: string 20 | federation: boolean 21 | } 22 | } 23 | 24 | export const schema: FastifySchema = { 25 | response: { 26 | '2xx': S.object() 27 | .additionalProperties(false) 28 | .required(['success', 'data']) 29 | .prop('success', S.boolean()) 30 | .prop( 31 | 'data', 32 | S.object() 33 | .required(['supergraphSdl', 'compositionId']) 34 | .prop('supergraphSdl', S.string()) 35 | .prop('compositionId', S.string()), 36 | ), 37 | }, 38 | body: S.object().additionalProperties(false).required(['graphName']).prop('graphName', graphName), 39 | } 40 | 41 | export default function supergraphSchema(fastify: FastifyInstance) { 42 | fastify.post('/schema/supergraph', { schema }, async (req, res) => { 43 | const graphRepository = new GraphRepository(fastify.knex) 44 | 45 | const graphExists = await graphRepository.exists({ 46 | name: req.body.graphName, 47 | }) 48 | if (!graphExists) { 49 | throw InvalidGraphNameError(req.body.graphName) 50 | } 51 | 52 | const serviceRepository = new ServiceRepository(fastify.knex) 53 | const schemaRepository = new SchemaRepository(fastify.knex) 54 | 55 | const serviceModels = await serviceRepository.findMany({ 56 | graphName: req.body.graphName, 57 | }) 58 | if (serviceModels.length === 0) { 59 | throw SupergraphCompositionError(`Can't compose supergraph. No service is registered.`) 60 | } 61 | 62 | const servicesWithoutRoutingUrl = serviceModels.filter((service) => !service.routingUrl) 63 | if (servicesWithoutRoutingUrl.length > 0) { 64 | throw SupergraphCompositionError( 65 | `Can't compose supergraph. Service '${servicesWithoutRoutingUrl[0].name}' has no routingUrl.`, 66 | ) 67 | } 68 | 69 | const allLatestServices = serviceModels.map((s) => ({ 70 | name: s.name, 71 | })) 72 | 73 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 74 | 75 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 76 | req.body.graphName, 77 | allLatestServices, 78 | ) 79 | 80 | if (findError) { 81 | throw SchemaVersionLookupError(findError.message) 82 | } 83 | 84 | if (!schemas.length) { 85 | return res.send({ 86 | success: true, 87 | data: {}, 88 | }) 89 | } 90 | 91 | const serviceSchemas = schemas.map((s) => ({ 92 | name: s.serviceName, 93 | url: s.routingUrl, 94 | typeDefs: s.typeDefs, 95 | })) 96 | 97 | const { error: schemaError, supergraphSdl } = composeAndValidateSchema(serviceSchemas) 98 | 99 | if (schemaError) { 100 | throw SchemaCompositionError(schemaError) 101 | } 102 | 103 | if (!supergraphSdl) { 104 | throw SupergraphCompositionError(schemaError) 105 | } 106 | 107 | return { 108 | success: true, 109 | data: { 110 | supergraphSdl, 111 | compositionId: hash(supergraphSdl), 112 | }, 113 | } 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | graphql-registry 3 |
4 | 5 |
6 | 7 |
8 | 9 | CI 10 | 11 | 12 | BENCH 13 | 14 | Coverage Status 15 |
16 | 17 |
18 | 19 | --- 20 | 21 | > **Note**: Are you looking for a Complete API Management for GraphQL Federation? 🔎 Have a look at: [WunderGraph Cosmo](https://github.com/wundergraph/cosmo) 22 | Includes Schema Registry, analytics, metrics, tracing, and routing. Available for 100% on-prem deployment or as a [Managed Service](https://cosmo.wundergraph.com/login). Apache 2.0 licensed, ensuring no vendor lock-in 🪄 23 | 24 | --- 25 | 26 | > There should be a **single source of truth** for registering and tracking the graph. 27 | 28 | ## Features 29 | 30 | - Create multiple graphs (for example, staging and production, or different development branches) 31 | - Stores versioned schemas for all GraphQL-federated services 32 | - Serves schema for GraphQL gateway based on provided services & their versions 33 | - Serves a supergraph schema for the GraphQL gateway 34 | - Validates new schema to be compatible with other running services 35 | - Validates that all client operations are supported by your schema 36 | - Calculates a schema coverage report from GraphQL operations 37 | - Validates if a schema update produce a breaking, dangerous or safe change 38 | - Lightweight authorization concept based on JWT. 39 | 40 | [**Read more**](https://principledgraphql.com/integrity#3-track-the-schema-in-a-registry) 41 | 42 | ## Examples 43 | 44 | - [Federation](./examples/mercurius-federation) with Mercurius. 45 | - [Federation](./examples/apollo-federation) with Apollo Gateway. 46 | - [Managed Federation](./examples/apollo-managed-federation) with Apollo Gateway. 47 | 48 | ## API 49 | 50 | Try all endpoints in [insomnia](https://insomnia.rest/run/?label=GraphQL%20Registry&uri=https%3A%2F%2Fraw.githubusercontent.com%2FStarpTech%2Fgraphql-registry%2Fmain%2Finsomnia.json) or read the api [documentation](./docs/api.md). 51 | 52 | ## Development 53 | 54 | Copy `.env.example` to `.env` 55 | 56 | ```sh 57 | # Install project 58 | npm install 59 | # Start postgres 60 | docker-compose up postgres 61 | # Create db schema 62 | npm run migrate:up 63 | # Watch mode 64 | npm run dev 65 | # Run tests 66 | npm run test 67 | ``` 68 | 69 | ## Benchmark 70 | 71 | Run a benchmark with: 72 | 73 | ```sh 74 | docker-compose up postgres 75 | docker-compose up --build app 76 | docker-compose run k6 run /benchmark/composed-schema.js 77 | ``` 78 | 79 | Our benchmark suite is running in the CI. 80 | 81 | ## Deployment 82 | 83 | GraphQL-Registry uses by default postgres as database. 84 | 85 | ```sh 86 | # Bootstrap database 87 | npm install && npm run migrate:up 88 | # Run service 89 | docker run -e DATABASE_URL="" starptech/graphql-registry:latest -p 3000:3000 90 | ``` 91 | 92 | [Available](/src/core/env.schema.ts) environment variables. 93 | 94 | ## Alpha version 95 | 96 | GraphQL Registry is currently highly under development. It means that we are still working on essential features like production-ready schema management, graph metrics and development tooling. GraphQL Registry can be evaluated anytime. Every feature is covered by integration tests. We rely on your feedback and sponsorship. Feel free to open an issue or feature request! 97 | 98 | ## Contributing 99 | 100 | ❤️ contributions! 101 | 102 | I will happily accept your pull request if it: 103 | 104 | - has tests 105 | - looks reasonable 106 | - follows the [code of conduct](./CODE_OF_CONDUCT.md) 107 | 108 | ### License 109 | 110 | GraphQL Registry is open-source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. You can [find it here](LICENSE). 111 | Why AGPLv3 and not MIT? Read the blog post from [plausible](https://plausible.io/blog/open-source-licenses) to learn more about our motivations. 112 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-validation.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should validate schema as valid', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'POST', 18 | url: '/schema/push', 19 | payload: { 20 | typeDefs: /* GraphQL */ ` 21 | type Query { 22 | hello: String 23 | } 24 | `, 25 | version: '1', 26 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 27 | serviceName: `${t.context.testPrefix}_foo`, 28 | graphName: `${t.context.graphName}`, 29 | }, 30 | }) 31 | 32 | t.is(res.statusCode, 200) 33 | 34 | res = await app.inject({ 35 | method: 'POST', 36 | url: '/schema/validate', 37 | payload: { 38 | typeDefs: /* GraphQL */ ` 39 | type Query { 40 | world: String 41 | } 42 | `, 43 | serviceName: `${t.context.testPrefix}_foo`, 44 | graphName: `${t.context.graphName}`, 45 | }, 46 | }) 47 | 48 | t.is(res.statusCode, 200) 49 | 50 | t.deepEqual( 51 | res.json(), 52 | { 53 | success: true, 54 | }, 55 | 'response payload match', 56 | ) 57 | }) 58 | 59 | test('Should validate schema as invalid', async (t) => { 60 | const app = build({ 61 | databaseConnectionUrl: t.context.connectionUrl, 62 | }) 63 | t.teardown(() => app.close()) 64 | 65 | let res = await app.inject({ 66 | method: 'POST', 67 | url: '/schema/push', 68 | payload: { 69 | typeDefs: /* GraphQL */ ` 70 | type Query { 71 | hello: String 72 | } 73 | `, 74 | version: '1', 75 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 76 | serviceName: `${t.context.testPrefix}_foo`, 77 | graphName: `${t.context.graphName}`, 78 | }, 79 | }) 80 | 81 | t.is(res.statusCode, 200) 82 | 83 | res = await app.inject({ 84 | method: 'POST', 85 | url: '/schema/validate', 86 | payload: { 87 | typeDefs: /* GraphQL */ ` 88 | type Query { 89 | hello: String22 90 | } 91 | `, 92 | serviceName: `${t.context.testPrefix}_foo`, 93 | graphName: `${t.context.graphName}`, 94 | }, 95 | }) 96 | 97 | t.is(res.statusCode, 400) 98 | 99 | t.deepEqual( 100 | res.json(), 101 | { 102 | success: false, 103 | error: 'Error: Unknown type: "String22".', 104 | }, 105 | 'response payload match', 106 | ) 107 | }) 108 | 109 | test('Should return 400 because type_def is missing', async (t) => { 110 | const app = build({ 111 | databaseConnectionUrl: t.context.connectionUrl, 112 | }) 113 | t.teardown(() => app.close()) 114 | 115 | let res = await app.inject({ 116 | method: 'POST', 117 | url: '/schema/validate', 118 | payload: { 119 | version: '1', 120 | serviceName: `${t.context.testPrefix}_foo`, 121 | graphName: `${t.context.graphName}`, 122 | }, 123 | }) 124 | 125 | t.is(res.statusCode, 400) 126 | t.deepEqual( 127 | res.json(), 128 | { 129 | success: false, 130 | error: "body should have required property 'typeDefs'", 131 | }, 132 | 'message', 133 | ) 134 | }) 135 | 136 | test('Should 400 when graph could not be found', async (t) => { 137 | const app = build({ 138 | databaseConnectionUrl: t.context.connectionUrl, 139 | }) 140 | t.teardown(() => app.close()) 141 | 142 | let res = await app.inject({ 143 | method: 'POST', 144 | url: '/schema/validate', 145 | payload: { 146 | typeDefs: /* GraphQL */ ` 147 | type Query { 148 | world: String 149 | } 150 | `, 151 | serviceName: `${t.context.testPrefix}_foo`, 152 | graphName: `${t.context.graphName}`, 153 | }, 154 | }) 155 | 156 | t.is(res.statusCode, 400) 157 | 158 | t.deepEqual( 159 | res.json(), 160 | { 161 | success: false, 162 | error: `Graph with name "${t.context.graphName}" does not exist`, 163 | }, 164 | 'response payload match', 165 | ) 166 | }) 167 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-coverage.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { FastifyInstance, FastifySchema } from 'fastify' 3 | import { composeAndValidateSchema } from '../../core/federation' 4 | import { SchemaManager, ServiceSchemaVersionMatch } from '../../core/manager/SchemaManager' 5 | import { 6 | InvalidGraphNameError, 7 | SchemaCompositionError, 8 | SchemaVersionLookupError, 9 | } from '../../core/errors' 10 | import SchemaRepository from '../../core/repositories/SchemaRepository' 11 | import ServiceRepository from '../../core/repositories/ServiceRepository' 12 | import GraphRepository from '../../core/repositories/GraphRepository' 13 | import { graphName, serviceName, version } from '../../core/shared-schemas' 14 | import { getSchemaCoverage } from '../../core/graphql-utils' 15 | import { Source } from 'graphql' 16 | import { ServiceVersionMatch } from '../../core/types' 17 | 18 | export interface RequestContext { 19 | Body: { 20 | graphName: string 21 | services?: ServiceVersionMatch[] 22 | documents: { 23 | name: string 24 | source: string 25 | }[] 26 | } 27 | } 28 | 29 | export const schema: FastifySchema = { 30 | response: { 31 | '2xx': S.object() 32 | .additionalProperties(false) 33 | .required(['success', 'data']) 34 | .prop('success', S.boolean()) 35 | .prop( 36 | 'data', 37 | S.object() 38 | .prop('sources', S.array().items(S.object().additionalProperties(true))) 39 | .prop('types', S.object().additionalProperties(true)), 40 | ), 41 | }, 42 | body: S.object() 43 | .additionalProperties(false) 44 | .required(['documents', 'graphName']) 45 | .prop('graphName', graphName) 46 | .prop( 47 | 'documents', 48 | S.array().items( 49 | S.object().required(['name', 'source']).prop('name', S.string()).prop('source', S.string()), 50 | ), 51 | ) 52 | .prop( 53 | 'services', 54 | S.array() 55 | .minItems(1) 56 | .items( 57 | S.object() 58 | .required(['name', 'version']) 59 | .prop('version', version) 60 | .prop('name', serviceName), 61 | ), 62 | ), 63 | } 64 | 65 | export default function schemaCoverage(fastify: FastifyInstance) { 66 | fastify.post('/schema/coverage', { schema }, async (req, res) => { 67 | const graphRepository = new GraphRepository(fastify.knex) 68 | 69 | const graphExists = await graphRepository.exists({ 70 | name: req.body.graphName, 71 | }) 72 | if (!graphExists) { 73 | throw InvalidGraphNameError(req.body.graphName) 74 | } 75 | 76 | const serviceRepository = new ServiceRepository(fastify.knex) 77 | const schemaRepository = new SchemaRepository(fastify.knex) 78 | 79 | const serviceModels = await serviceRepository.findMany({ 80 | graphName: req.body.graphName, 81 | }) 82 | 83 | if (serviceModels.length === 0) { 84 | return res.send({ 85 | success: true, 86 | data: {}, 87 | }) 88 | } 89 | 90 | let serviceVersionMatches: ServiceSchemaVersionMatch[] = [] 91 | 92 | if (req.body.services && req.body.services?.length > 0) { 93 | serviceVersionMatches = req.body.services.map((s) => ({ 94 | name: s.name, 95 | version: s.version, 96 | })) 97 | } else { 98 | serviceVersionMatches = serviceModels.map((s) => ({ name: s.name })) 99 | } 100 | 101 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 102 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 103 | req.body.graphName, 104 | serviceVersionMatches, 105 | ) 106 | 107 | if (findError) { 108 | throw SchemaVersionLookupError(findError.message) 109 | } 110 | 111 | let serviceSchemas = schemas.map((s) => ({ 112 | name: s.serviceName, 113 | url: s.routingUrl, 114 | typeDefs: s.typeDefs, 115 | })) 116 | 117 | let compositionResult = composeAndValidateSchema(serviceSchemas) 118 | if (compositionResult.error) { 119 | throw SchemaCompositionError(compositionResult.error) 120 | } 121 | if (!compositionResult.schema) { 122 | throw SchemaCompositionError(compositionResult.error) 123 | } 124 | 125 | const sources = req.body.documents.map((document) => new Source(document.source, document.name)) 126 | const changesReport = getSchemaCoverage(compositionResult.schema, sources) 127 | 128 | return { 129 | success: true, 130 | data: changesReport, 131 | } 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /src/registry/document-validation/document-validation.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { cleanTest, createTestContext, createTestPrefix, TestContext } from '../../core/test-util' 4 | 5 | const test = anyTest as TestInterface 6 | test.before(createTestContext()) 7 | test.beforeEach(createTestPrefix()) 8 | test.after.always('cleanup', cleanTest()) 9 | 10 | test('Should validate document as valid', async (t) => { 11 | const app = build({ 12 | databaseConnectionUrl: t.context.connectionUrl, 13 | }) 14 | t.teardown(() => app.close()) 15 | 16 | let res = await app.inject({ 17 | method: 'POST', 18 | url: '/schema/push', 19 | payload: { 20 | typeDefs: /* GraphQL */ ` 21 | type Query { 22 | hello: String 23 | } 24 | `, 25 | version: '1', 26 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 27 | serviceName: `${t.context.testPrefix}_foo`, 28 | graphName: `${t.context.graphName}`, 29 | }, 30 | }) 31 | 32 | t.is(res.statusCode, 200) 33 | 34 | res = await app.inject({ 35 | method: 'POST', 36 | url: '/schema/push', 37 | payload: { 38 | typeDefs: /* GraphQL */ ` 39 | type Query { 40 | bar: String 41 | } 42 | `, 43 | version: '1', 44 | routingUrl: `http://${t.context.testPrefix}_bar:3000/api/graphql`, 45 | serviceName: `${t.context.testPrefix}_bar`, 46 | graphName: `${t.context.graphName}`, 47 | }, 48 | }) 49 | 50 | t.is(res.statusCode, 200) 51 | 52 | res = await app.inject({ 53 | method: 'POST', 54 | url: '/document/validate', 55 | payload: { 56 | documents: [ 57 | /* GraphQL */ ` 58 | query { 59 | hello 60 | } 61 | `, 62 | ], 63 | graphName: `${t.context.graphName}`, 64 | }, 65 | }) 66 | 67 | t.is(res.statusCode, 200) 68 | 69 | t.deepEqual( 70 | res.json(), 71 | { 72 | success: true, 73 | }, 74 | 'response payload match', 75 | ) 76 | }) 77 | 78 | test('Should validate document as invalid because field does not exist', async (t) => { 79 | const app = build({ 80 | databaseConnectionUrl: t.context.connectionUrl, 81 | }) 82 | t.teardown(() => app.close()) 83 | 84 | let res = await app.inject({ 85 | method: 'POST', 86 | url: '/schema/push', 87 | payload: { 88 | typeDefs: /* GraphQL */ ` 89 | type Query { 90 | hello: String 91 | } 92 | `, 93 | version: '1', 94 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 95 | serviceName: `${t.context.testPrefix}_foo`, 96 | graphName: `${t.context.graphName}`, 97 | }, 98 | }) 99 | 100 | t.is(res.statusCode, 200) 101 | 102 | res = await app.inject({ 103 | method: 'POST', 104 | url: '/document/validate', 105 | payload: { 106 | documents: [ 107 | /* GraphQL */ ` 108 | query { 109 | world 110 | } 111 | `, 112 | ], 113 | graphName: `${t.context.graphName}`, 114 | }, 115 | }) 116 | 117 | t.is(res.statusCode, 400) 118 | 119 | t.deepEqual( 120 | res.json(), 121 | { 122 | success: false, 123 | error: [ 124 | { 125 | source: { 126 | body: '\n query {\n world\n }\n ', 127 | name: 'GraphQL request', 128 | locationOffset: { line: 1, column: 1 }, 129 | }, 130 | errors: [ 131 | { 132 | message: 'Cannot query field "world" on type "Query".', 133 | locations: [{ line: 3, column: 13 }], 134 | }, 135 | ], 136 | deprecated: [], 137 | }, 138 | ], 139 | }, 140 | 'response payload match', 141 | ) 142 | }) 143 | 144 | test('Should return 400 error when graph does not exist', async (t) => { 145 | const app = build({ 146 | databaseConnectionUrl: t.context.connectionUrl, 147 | }) 148 | t.teardown(() => app.close()) 149 | 150 | const res = await app.inject({ 151 | method: 'POST', 152 | url: '/document/validate', 153 | payload: { 154 | documents: [ 155 | /* GraphQL */ ` 156 | query { 157 | world 158 | } 159 | `, 160 | ], 161 | graphName: `${t.context.graphName}`, 162 | }, 163 | }) 164 | 165 | t.is(res.statusCode, 400) 166 | 167 | const response = res.json() 168 | 169 | t.false(response.success) 170 | 171 | t.is(response.error, `Graph with name "${t.context.graphName}" does not exist`) 172 | }) 173 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-check.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { FastifyInstance, FastifySchema } from 'fastify' 3 | import { CriticalityLevel, diff } from '@graphql-inspector/core' 4 | import { composeAndValidateSchema } from '../../core/federation' 5 | import { SchemaManager } from '../../core/manager/SchemaManager' 6 | import { 7 | InvalidGraphNameError, 8 | SchemaCompositionError, 9 | SchemaVersionLookupError, 10 | } from '../../core/errors' 11 | import SchemaRepository from '../../core/repositories/SchemaRepository' 12 | import ServiceRepository from '../../core/repositories/ServiceRepository' 13 | import GraphRepository from '../../core/repositories/GraphRepository' 14 | import { graphName, serviceName, typeDefs } from '../../core/shared-schemas' 15 | import { getChangeSet } from '../../core/graphql-utils' 16 | 17 | export interface RequestContext { 18 | Body: { 19 | serviceName: string 20 | typeDefs: string 21 | graphName: string 22 | } 23 | } 24 | 25 | export const schema: FastifySchema = { 26 | response: { 27 | '2xx': S.object() 28 | .additionalProperties(false) 29 | .required(['success', 'data']) 30 | .prop('success', S.boolean()) 31 | .prop( 32 | 'data', 33 | S.object() 34 | .prop('breakingChangeAdded', S.boolean()) 35 | .prop('deprecationAdded', S.boolean()) 36 | .prop( 37 | 'report', 38 | S.array().items( 39 | S.object() 40 | .required(['type', 'message', 'level', 'path']) 41 | .prop('type', S.string()) 42 | .prop('message', S.string()) 43 | .prop('level', S.string()) 44 | .prop('path', S.string()) 45 | .prop('reason', S.string()), 46 | ), 47 | ), 48 | ), 49 | }, 50 | body: S.object() 51 | .additionalProperties(false) 52 | .required(['typeDefs', 'serviceName', 'graphName']) 53 | .prop('graphName', graphName) 54 | .prop('typeDefs', typeDefs) 55 | .prop('serviceName', serviceName), 56 | } 57 | 58 | export default function schemaCheck(fastify: FastifyInstance) { 59 | fastify.post('/schema/check', { schema }, async (req, res) => { 60 | const graphRepository = new GraphRepository(fastify.knex) 61 | 62 | const graphExists = await graphRepository.exists({ 63 | name: req.body.graphName, 64 | }) 65 | if (!graphExists) { 66 | throw InvalidGraphNameError(req.body.graphName) 67 | } 68 | 69 | const serviceRepository = new ServiceRepository(fastify.knex) 70 | const schemaRepository = new SchemaRepository(fastify.knex) 71 | 72 | const serviceModels = await serviceRepository.findMany({ 73 | graphName: req.body.graphName, 74 | }) 75 | 76 | if (serviceModels.length === 0) { 77 | return res.send({ 78 | success: true, 79 | data: {}, 80 | }) 81 | } 82 | 83 | const allLatestServices = serviceModels.map((s) => ({ name: s.name })) 84 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 85 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 86 | req.body.graphName, 87 | allLatestServices, 88 | ) 89 | 90 | if (findError) { 91 | throw SchemaVersionLookupError(findError.message) 92 | } 93 | 94 | let serviceSchemas = schemas.map((s) => ({ 95 | name: s.serviceName, 96 | url: s.routingUrl, 97 | typeDefs: s.typeDefs, 98 | })) 99 | 100 | let original = composeAndValidateSchema(serviceSchemas) 101 | if (!original.schema) { 102 | throw SchemaCompositionError(original.error) 103 | } 104 | if (original.error) { 105 | throw SchemaCompositionError(original.error) 106 | } 107 | 108 | serviceSchemas = serviceSchemas 109 | .filter((schema) => schema.name !== req.body.serviceName) 110 | .concat({ 111 | name: req.body.serviceName, 112 | url: '', 113 | typeDefs: req.body.typeDefs, 114 | }) 115 | 116 | const updated = composeAndValidateSchema(serviceSchemas) 117 | if (updated.error) { 118 | throw SchemaCompositionError(updated.error) 119 | } 120 | if (!updated.schema) { 121 | throw SchemaCompositionError(updated.error) 122 | } 123 | 124 | const changesReport = getChangeSet(original.schema, updated.schema) 125 | 126 | return { 127 | success: true, 128 | data: { 129 | breakingChangeAdded: changesReport.breakingChangeAdded, 130 | deprecationAdded: changesReport.deprecationAdded, 131 | report: changesReport.changes, 132 | }, 133 | } 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /src/migrations/20210504193054_initial_schema.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { GraphDBModel } from '../core/models/graphModel' 3 | import { SchemaDBModel } from '../core/models/schemaModel' 4 | import { SchemaTagDBModel } from '../core/models/schemaTagModel' 5 | import { ServiceDBModel } from '../core/models/serviceModel' 6 | 7 | export async function up(knex: Knex): Promise { 8 | return knex.schema 9 | .createTable(GraphDBModel.table, (table) => { 10 | table.increments(GraphDBModel.field('id')).primary().notNullable() 11 | 12 | table.string(GraphDBModel.field('name')).unique().notNullable() 13 | table.boolean(GraphDBModel.field('isActive')).notNullable().defaultTo(true) 14 | table 15 | .timestamp(GraphDBModel.field('createdAt'), { useTz: true }) 16 | .notNullable() 17 | .defaultTo(knex.fn.now()) 18 | table 19 | .timestamp(GraphDBModel.field('updatedAt'), { useTz: true }) 20 | .notNullable() 21 | .defaultTo(knex.fn.now()) 22 | 23 | table.index([GraphDBModel.field('isActive'), GraphDBModel.field('name')]) 24 | }) 25 | .createTable(ServiceDBModel.table, (table) => { 26 | table.increments(ServiceDBModel.field('id')).primary() 27 | 28 | table.string(ServiceDBModel.field('name')).notNullable() 29 | table.boolean(ServiceDBModel.field('isActive')).notNullable().defaultTo(true) 30 | table.string(ServiceDBModel.field('routingUrl')).notNullable() 31 | table 32 | .timestamp(ServiceDBModel.field('createdAt'), { useTz: true }) 33 | .notNullable() 34 | .defaultTo(knex.fn.now()) 35 | table 36 | .timestamp(ServiceDBModel.field('updatedAt'), { useTz: true }) 37 | .notNullable() 38 | .defaultTo(knex.fn.now()) 39 | 40 | table 41 | .integer(ServiceDBModel.field('graphId')) 42 | .unsigned() 43 | .references(GraphDBModel.field('id')) 44 | .inTable(GraphDBModel.table) 45 | .onDelete('CASCADE') 46 | .index() 47 | 48 | table.index([ServiceDBModel.field('isActive'), ServiceDBModel.field('name')]) 49 | table.unique([ServiceDBModel.field('graphId'), ServiceDBModel.field('name')]) 50 | table.unique([ServiceDBModel.field('graphId'), ServiceDBModel.field('routingUrl')]) 51 | }) 52 | .createTable(SchemaDBModel.table, (table) => { 53 | table.increments(SchemaDBModel.field('id')).primary() 54 | 55 | table.text(SchemaDBModel.field('typeDefs')) 56 | table.boolean(SchemaDBModel.field('isActive')).notNullable().defaultTo(true) 57 | table 58 | .timestamp(SchemaDBModel.field('createdAt'), { useTz: true }) 59 | .notNullable() 60 | .defaultTo(knex.fn.now()) 61 | table 62 | .timestamp(SchemaDBModel.field('updatedAt'), { useTz: true }) 63 | .notNullable() 64 | .defaultTo(knex.fn.now()) 65 | table 66 | .integer(SchemaDBModel.field('graphId')) 67 | .unsigned() 68 | .references(GraphDBModel.field('id')) 69 | .inTable(GraphDBModel.table) 70 | .index() 71 | 72 | table 73 | .integer(SchemaDBModel.field('serviceId')) 74 | .unsigned() 75 | .references(ServiceDBModel.field('id')) 76 | .inTable(ServiceDBModel.table) 77 | .index() 78 | 79 | table.index([SchemaDBModel.field('isActive')]) 80 | table.index([SchemaDBModel.field('typeDefs')]) 81 | }) 82 | .createTable(SchemaTagDBModel.table, (table) => { 83 | table.increments(SchemaTagDBModel.field('id')).primary() 84 | 85 | table.string(SchemaTagDBModel.field('version')) 86 | table.boolean(SchemaTagDBModel.field('isActive')).notNullable().defaultTo(true) 87 | table 88 | .timestamp(SchemaTagDBModel.field('createdAt'), { useTz: true }) 89 | .notNullable() 90 | .defaultTo(knex.fn.now()) 91 | 92 | table 93 | .integer(SchemaTagDBModel.field('schemaId')) 94 | .unsigned() 95 | .references(SchemaDBModel.field('id')) 96 | .inTable(SchemaDBModel.table) 97 | .index() 98 | 99 | table 100 | .integer(SchemaTagDBModel.field('serviceId')) 101 | .unsigned() 102 | .references(ServiceDBModel.field('id')) 103 | .inTable(ServiceDBModel.table) 104 | .index() 105 | 106 | table.index([SchemaTagDBModel.field('version')]) 107 | }) 108 | } 109 | 110 | export async function down(knex: Knex): Promise { 111 | return knex.schema 112 | .raw(`DROP TABLE ${GraphDBModel.table} CASCADE`) 113 | .raw(`DROP TABLE ${SchemaDBModel.table} CASCADE`) 114 | .raw(`DROP TABLE ${SchemaTagDBModel.table} CASCADE`) 115 | .raw(`DROP TABLE ${ServiceDBModel.table} CASCADE`) 116 | } 117 | -------------------------------------------------------------------------------- /src/core/repositories/ServiceRepository.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { GraphDBModel } from '../models/graphModel' 3 | import { ServiceDBModel } from '../models/serviceModel' 4 | 5 | export default class ServiceRepository { 6 | private knex: Knex 7 | constructor(knex: Knex) { 8 | this.knex = knex 9 | } 10 | findFirst({ graphName, name }: { graphName: string; name: string }) { 11 | const knex = this.knex 12 | const table = ServiceDBModel.table 13 | return knex 14 | .from(table) 15 | .join( 16 | GraphDBModel.table, 17 | ServiceDBModel.fullName('graphId'), 18 | '=', 19 | GraphDBModel.fullName('id'), 20 | ) 21 | .where({ 22 | [GraphDBModel.fullName('isActive')]: true, 23 | [GraphDBModel.fullName('name')]: graphName, 24 | [ServiceDBModel.fullName('name')]: name, 25 | }) 26 | .select(`${table}.*`) 27 | .first() 28 | } 29 | findByRoutingUrl({ graphName, routingUrl }: { graphName: string; routingUrl: string }) { 30 | const knex = this.knex 31 | const table = ServiceDBModel.table 32 | return knex 33 | .from(table) 34 | .join( 35 | GraphDBModel.table, 36 | ServiceDBModel.fullName('graphId'), 37 | '=', 38 | GraphDBModel.fullName('id'), 39 | ) 40 | .where({ 41 | [GraphDBModel.fullName('isActive')]: true, 42 | [GraphDBModel.fullName('name')]: graphName, 43 | [ServiceDBModel.fullName('routingUrl')]: routingUrl, 44 | }) 45 | .select(`${table}.*`) 46 | .first() 47 | } 48 | findByNames( 49 | { graphName }: { graphName: string }, 50 | serviceNames: string[], 51 | ): Promise { 52 | const knex = this.knex 53 | const table = ServiceDBModel.table 54 | return knex 55 | .from(table) 56 | .select([`${table}.*`]) 57 | .join( 58 | GraphDBModel.table, 59 | ServiceDBModel.fullName('graphId'), 60 | '=', 61 | GraphDBModel.fullName('id'), 62 | ) 63 | .where({ 64 | [GraphDBModel.fullName('isActive')]: true, 65 | [GraphDBModel.fullName('name')]: graphName, 66 | [ServiceDBModel.fullName('isActive')]: true, 67 | }) 68 | .whereIn(ServiceDBModel.fullName('name'), serviceNames) 69 | .orderBy(ServiceDBModel.fullName('updatedAt'), 'desc') 70 | } 71 | findManyExceptWithName( 72 | { graphName }: { graphName: string }, 73 | exceptService: string, 74 | ): Promise { 75 | const knex = this.knex 76 | const table = ServiceDBModel.table 77 | return knex 78 | .from(table) 79 | .select([`${table}.*`]) 80 | .join( 81 | GraphDBModel.table, 82 | ServiceDBModel.fullName('graphId'), 83 | '=', 84 | GraphDBModel.fullName('id'), 85 | ) 86 | .where({ 87 | [GraphDBModel.fullName('isActive')]: true, 88 | [GraphDBModel.fullName('name')]: graphName, 89 | [ServiceDBModel.fullName('isActive')]: true, 90 | }) 91 | .whereNot(ServiceDBModel.fullName('name'), exceptService) 92 | .orderBy(ServiceDBModel.fullName('updatedAt'), 'desc') 93 | } 94 | findMany( 95 | { graphName }: { graphName: string }, 96 | where: Partial = {}, 97 | ): Promise { 98 | const knex = this.knex 99 | const table = ServiceDBModel.table 100 | return knex 101 | .from(table) 102 | .select([`${table}.*`]) 103 | .join( 104 | GraphDBModel.table, 105 | ServiceDBModel.fullName('graphId'), 106 | '=', 107 | GraphDBModel.fullName('id'), 108 | ) 109 | .where(where) 110 | .where({ 111 | [GraphDBModel.fullName('isActive')]: true, 112 | [GraphDBModel.fullName('name')]: graphName, 113 | [ServiceDBModel.fullName('isActive')]: true, 114 | }) 115 | .orderBy(ServiceDBModel.fullName('updatedAt'), 'desc') 116 | } 117 | async create(entity: Omit) { 118 | const knex = this.knex 119 | const table = ServiceDBModel.table 120 | 121 | const [first] = await knex(table) 122 | .insert({ 123 | ...entity, 124 | createdAt: new Date(), 125 | updatedAt: new Date(), 126 | }) 127 | .returning('*') 128 | 129 | return first 130 | } 131 | async deleteByGraphId(graphId: number) { 132 | const knex = this.knex 133 | const table = ServiceDBModel.table 134 | return await knex(table) 135 | .where(ServiceDBModel.field('graphId'), graphId) 136 | .delete() 137 | .returning[]>(ServiceDBModel.field('id')) 138 | } 139 | async updateOne( 140 | what: Partial, 141 | where: Partial, 142 | ): Promise { 143 | const knex = this.knex 144 | const table = ServiceDBModel.table 145 | const [first] = await knex(table) 146 | .update(what) 147 | .where(where) 148 | .limit(1) 149 | .returning('*') 150 | return first 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-coverage.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { 4 | cleanTest, 5 | createTestContext, 6 | createTestPrefix, 7 | TestContext, 8 | trimDoc, 9 | } from '../../core/test-util' 10 | 11 | const test = anyTest as TestInterface 12 | test.before(createTestContext()) 13 | test.beforeEach(createTestPrefix()) 14 | test.after.always('cleanup', cleanTest()) 15 | 16 | test('Should calculate the schema coverage between provided documents and latest schema', async (t) => { 17 | const app = build({ 18 | databaseConnectionUrl: t.context.connectionUrl, 19 | }) 20 | t.teardown(() => app.close()) 21 | 22 | let res = await app.inject({ 23 | method: 'POST', 24 | url: '/schema/push', 25 | payload: { 26 | typeDefs: /* GraphQL */ ` 27 | type Query { 28 | hello: String 29 | } 30 | `, 31 | version: '1', 32 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 33 | serviceName: `${t.context.testPrefix}_foo`, 34 | graphName: `${t.context.graphName}`, 35 | }, 36 | }) 37 | 38 | t.is(res.statusCode, 200) 39 | 40 | res = await app.inject({ 41 | method: 'POST', 42 | url: '/schema/coverage', 43 | payload: { 44 | documents: [ 45 | { 46 | name: 'foo.graphql', 47 | source: trimDoc/* GraphQL */ ` 48 | query { 49 | hello 50 | } 51 | `, 52 | }, 53 | ], 54 | graphName: `${t.context.graphName}`, 55 | }, 56 | }) 57 | 58 | t.is(res.statusCode, 200) 59 | 60 | t.deepEqual( 61 | res.json(), 62 | { 63 | success: true, 64 | data: { 65 | sources: [ 66 | { body: 'query{hello}', name: 'foo.graphql', locationOffset: { line: 1, column: 1 } }, 67 | ], 68 | types: { 69 | Query: { 70 | hits: 1, 71 | type: 'Query', 72 | children: { hello: { hits: 1, locations: { 'foo.graphql': [{ start: 6, end: 11 }] } } }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | 'response payload match', 78 | ) 79 | }) 80 | 81 | test('Should calculate the schema coverage based on the set of service versions', async (t) => { 82 | const app = build({ 83 | databaseConnectionUrl: t.context.connectionUrl, 84 | }) 85 | t.teardown(() => app.close()) 86 | 87 | let res = await app.inject({ 88 | method: 'POST', 89 | url: '/schema/push', 90 | payload: { 91 | typeDefs: /* GraphQL */ ` 92 | type Query { 93 | hello: String 94 | } 95 | `, 96 | version: '1', 97 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 98 | serviceName: `${t.context.testPrefix}_foo`, 99 | graphName: `${t.context.graphName}`, 100 | }, 101 | }) 102 | 103 | t.is(res.statusCode, 200) 104 | 105 | res = await app.inject({ 106 | method: 'POST', 107 | url: '/schema/push', 108 | payload: { 109 | typeDefs: /* GraphQL */ ` 110 | type Query { 111 | world: String 112 | } 113 | `, 114 | version: '2', 115 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 116 | serviceName: `${t.context.testPrefix}_foo`, 117 | graphName: `${t.context.graphName}`, 118 | }, 119 | }) 120 | 121 | t.is(res.statusCode, 200) 122 | 123 | res = await app.inject({ 124 | method: 'POST', 125 | url: '/schema/coverage', 126 | payload: { 127 | services: [{ name: `${t.context.testPrefix}_foo`, version: '2' }], 128 | documents: [ 129 | { 130 | name: 'foo.graphql', 131 | source: trimDoc/* GraphQL */ ` 132 | query { 133 | hello 134 | } 135 | `, 136 | }, 137 | ], 138 | graphName: `${t.context.graphName}`, 139 | }, 140 | }) 141 | 142 | t.is(res.statusCode, 200) 143 | 144 | t.deepEqual( 145 | res.json(), 146 | { 147 | success: true, 148 | data: { 149 | sources: [ 150 | { body: 'query{hello}', name: 'foo.graphql', locationOffset: { line: 1, column: 1 } }, 151 | ], 152 | types: { 153 | Query: { hits: 0, type: 'Query', children: { world: { hits: 0, locations: {} } } }, 154 | }, 155 | }, 156 | }, 157 | 'response payload match', 158 | ) 159 | 160 | res = await app.inject({ 161 | method: 'POST', 162 | url: '/schema/coverage', 163 | payload: { 164 | services: [{ name: `${t.context.testPrefix}_foo`, version: '1' }], 165 | documents: [ 166 | { 167 | name: 'foo.graphql', 168 | source: trimDoc/* GraphQL */ ` 169 | query { 170 | hello 171 | } 172 | `, 173 | }, 174 | ], 175 | graphName: `${t.context.graphName}`, 176 | }, 177 | }) 178 | 179 | t.is(res.statusCode, 200) 180 | 181 | t.deepEqual( 182 | res.json(), 183 | { 184 | success: true, 185 | data: { 186 | sources: [ 187 | { body: 'query{hello}', name: 'foo.graphql', locationOffset: { line: 1, column: 1 } }, 188 | ], 189 | types: { 190 | Query: { 191 | hits: 1, 192 | type: 'Query', 193 | children: { hello: { hits: 1, locations: { 'foo.graphql': [{ start: 6, end: 11 }] } } }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | 'response payload match', 199 | ) 200 | }) 201 | -------------------------------------------------------------------------------- /src/core/repositories/SchemaRepository.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { GraphDBModel } from '../models/graphModel' 3 | import { SchemaDBModel } from '../models/schemaModel' 4 | import { SchemaTagDBModel } from '../models/schemaTagModel' 5 | import { ServiceDBModel } from '../models/serviceModel' 6 | import { LastUpdatedSchema } from '../types' 7 | 8 | export default class SchemaRepository { 9 | private knex: Knex 10 | constructor(knex: Knex) { 11 | this.knex = knex 12 | } 13 | findById(id: number) { 14 | const knex = this.knex 15 | const table = SchemaDBModel.table 16 | return knex 17 | .from(table) 18 | .where({ 19 | [SchemaDBModel.fullName('isActive')]: true, 20 | [SchemaDBModel.fullName('id')]: id, 21 | }) 22 | .first() 23 | } 24 | findFirst({ 25 | graphName, 26 | typeDefs, 27 | serviceName, 28 | }: { 29 | graphName: string 30 | typeDefs: string 31 | serviceName: string 32 | }) { 33 | const knex = this.knex 34 | const table = SchemaDBModel.table 35 | return knex 36 | .from(table) 37 | .join(GraphDBModel.table, SchemaDBModel.fullName('graphId'), '=', GraphDBModel.fullName('id')) 38 | .join( 39 | ServiceDBModel.table, 40 | SchemaDBModel.fullName('serviceId'), 41 | '=', 42 | ServiceDBModel.fullName('id'), 43 | ) 44 | .where({ 45 | [GraphDBModel.fullName('isActive')]: true, 46 | [GraphDBModel.fullName('name')]: graphName, 47 | [ServiceDBModel.fullName('isActive')]: true, 48 | [ServiceDBModel.fullName('name')]: serviceName, 49 | [SchemaDBModel.fullName('typeDefs')]: typeDefs, 50 | }) 51 | .select(`${table}.*`) 52 | .first() 53 | } 54 | findLastUpdated({ serviceName, graphName }: { graphName: string; serviceName: string }) { 55 | const knex = this.knex 56 | const table = SchemaDBModel.table 57 | return knex 58 | .from(table) 59 | .select([ 60 | SchemaDBModel.fullName('id'), 61 | SchemaDBModel.fullName('typeDefs'), 62 | SchemaDBModel.fullName('updatedAt'), 63 | SchemaTagDBModel.fullName('version'), 64 | ]) 65 | .join(GraphDBModel.table, SchemaDBModel.fullName('graphId'), '=', GraphDBModel.fullName('id')) 66 | .join( 67 | ServiceDBModel.table, 68 | SchemaDBModel.fullName('serviceId'), 69 | '=', 70 | ServiceDBModel.fullName('id'), 71 | ) 72 | .join( 73 | SchemaTagDBModel.table, 74 | SchemaDBModel.fullName('id'), 75 | '=', 76 | SchemaTagDBModel.fullName('schemaId'), 77 | ) 78 | .where({ 79 | [GraphDBModel.fullName('isActive')]: true, 80 | [GraphDBModel.fullName('name')]: graphName, 81 | [ServiceDBModel.fullName('isActive')]: true, 82 | [ServiceDBModel.fullName('name')]: serviceName, 83 | [SchemaTagDBModel.fullName('isActive')]: true, 84 | [SchemaDBModel.fullName('isActive')]: true, 85 | }) 86 | .orderBy([ 87 | { column: SchemaDBModel.fullName('updatedAt'), order: 'desc' }, 88 | { column: SchemaTagDBModel.fullName('createdAt'), order: 'desc' }, 89 | ]) 90 | .first() 91 | } 92 | findBySchemaTagVersion({ 93 | graphName, 94 | version, 95 | serviceName, 96 | }: { 97 | graphName: string 98 | version: string 99 | serviceName: string 100 | }) { 101 | const knex = this.knex 102 | const table = SchemaDBModel.table 103 | return knex 104 | .from(table) 105 | .join(GraphDBModel.table, SchemaDBModel.fullName('graphId'), '=', GraphDBModel.fullName('id')) 106 | .join( 107 | ServiceDBModel.table, 108 | SchemaDBModel.fullName('serviceId'), 109 | '=', 110 | ServiceDBModel.fullName('id'), 111 | ) 112 | .join( 113 | SchemaTagDBModel.table, 114 | SchemaDBModel.fullName('id'), 115 | '=', 116 | SchemaTagDBModel.fullName('schemaId'), 117 | ) 118 | .where({ 119 | [GraphDBModel.fullName('isActive')]: true, 120 | [GraphDBModel.fullName('name')]: graphName, 121 | [ServiceDBModel.fullName('isActive')]: true, 122 | [ServiceDBModel.fullName('name')]: serviceName, 123 | [SchemaDBModel.fullName('isActive')]: true, 124 | [SchemaTagDBModel.fullName('isActive')]: true, 125 | [SchemaTagDBModel.fullName('version')]: version, 126 | }) 127 | .select(`${table}.*`) 128 | .first() 129 | } 130 | async create(entity: Omit) { 131 | const knex = this.knex 132 | const table = SchemaDBModel.table 133 | const [first] = await knex(table) 134 | .insert({ 135 | ...entity, 136 | createdAt: new Date(), 137 | updatedAt: new Date(), 138 | }) 139 | .returning('*') 140 | 141 | return first 142 | } 143 | async updateById( 144 | schemaId: number, 145 | entity: Partial, 146 | ): Promise { 147 | const knex = this.knex 148 | const table = SchemaDBModel.table 149 | const [first] = await knex(table) 150 | .update(entity) 151 | .where(SchemaDBModel.fullName('id'), '=', schemaId) 152 | .limit(1) 153 | .returning('*') 154 | 155 | return first 156 | } 157 | async deleteByGraphId(graphId: number) { 158 | const knex = this.knex 159 | const table = GraphDBModel.table 160 | return await knex(table) 161 | .where(SchemaDBModel.field('graphId'), graphId) 162 | .delete() 163 | .returning[]>(SchemaDBModel.field('id')) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/registry/schema-validation/schema-check.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { 4 | cleanTest, 5 | createTestContext, 6 | createTestPrefix, 7 | TestContext, 8 | trimDoc, 9 | } from '../../core/test-util' 10 | 11 | const test = anyTest as TestInterface 12 | test.before(createTestContext()) 13 | test.beforeEach(createTestPrefix()) 14 | test.after.always('cleanup', cleanTest()) 15 | 16 | test('Should check the schema for changes with the latest registry state', async (t) => { 17 | const app = build({ 18 | databaseConnectionUrl: t.context.connectionUrl, 19 | }) 20 | t.teardown(() => app.close()) 21 | 22 | let res = await app.inject({ 23 | method: 'POST', 24 | url: '/schema/push', 25 | payload: { 26 | typeDefs: /* GraphQL */ ` 27 | type Query { 28 | hello: String 29 | } 30 | `, 31 | version: '1', 32 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 33 | serviceName: `${t.context.testPrefix}_foo`, 34 | graphName: `${t.context.graphName}`, 35 | }, 36 | }) 37 | 38 | t.is(res.statusCode, 200) 39 | 40 | res = await app.inject({ 41 | method: 'POST', 42 | url: '/schema/check', 43 | payload: { 44 | typeDefs: /* GraphQL */ ` 45 | type Query { 46 | hello: String 47 | world: String 48 | } 49 | `, 50 | serviceName: `${t.context.testPrefix}_foo`, 51 | graphName: `${t.context.graphName}`, 52 | }, 53 | }) 54 | 55 | t.is(res.statusCode, 200) 56 | 57 | t.deepEqual( 58 | res.json(), 59 | { 60 | success: true, 61 | data: { 62 | breakingChangeAdded: false, 63 | deprecationAdded: false, 64 | report: [ 65 | { 66 | level: 'NON_BREAKING', 67 | type: 'FIELD_ADDED', 68 | message: "Field 'world' was added to object type 'Query'", 69 | path: 'Query.world', 70 | }, 71 | ], 72 | }, 73 | }, 74 | 'response payload match', 75 | ) 76 | }) 77 | 78 | test('Should detect a breaking change', async (t) => { 79 | const app = build({ 80 | databaseConnectionUrl: t.context.connectionUrl, 81 | }) 82 | t.teardown(() => app.close()) 83 | 84 | let res = await app.inject({ 85 | method: 'POST', 86 | url: '/schema/push', 87 | payload: { 88 | typeDefs: /* GraphQL */ ` 89 | type Query { 90 | hello: String 91 | world: String 92 | } 93 | `, 94 | version: '1', 95 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 96 | serviceName: `${t.context.testPrefix}_foo`, 97 | graphName: `${t.context.graphName}`, 98 | }, 99 | }) 100 | 101 | t.is(res.statusCode, 200) 102 | 103 | res = await app.inject({ 104 | method: 'POST', 105 | url: '/schema/check', 106 | payload: { 107 | typeDefs: trimDoc/* GraphQL */ ` 108 | type Query { 109 | hello: String 110 | } 111 | `, 112 | serviceName: `${t.context.testPrefix}_foo`, 113 | graphName: `${t.context.graphName}`, 114 | }, 115 | }) 116 | 117 | t.is(res.statusCode, 200) 118 | 119 | t.deepEqual( 120 | res.json(), 121 | { 122 | success: true, 123 | data: { 124 | breakingChangeAdded: true, 125 | deprecationAdded: false, 126 | report: [ 127 | { 128 | reason: 129 | 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.', 130 | level: 'BREAKING', 131 | type: 'FIELD_REMOVED', 132 | message: "Field 'world' was removed from object type 'Query'", 133 | path: 'Query.world', 134 | }, 135 | ], 136 | }, 137 | }, 138 | 'response payload match', 139 | ) 140 | }) 141 | 142 | test('Should return 400 because type_def is missing', async (t) => { 143 | const app = build({ 144 | databaseConnectionUrl: t.context.connectionUrl, 145 | }) 146 | t.teardown(() => app.close()) 147 | 148 | let res = await app.inject({ 149 | method: 'POST', 150 | url: '/schema/check', 151 | payload: { 152 | serviceName: `${t.context.testPrefix}_foo`, 153 | graphName: `${t.context.graphName}`, 154 | }, 155 | }) 156 | 157 | t.is(res.statusCode, 400) 158 | t.deepEqual( 159 | res.json(), 160 | { 161 | success: false, 162 | error: "body should have required property 'typeDefs'", 163 | }, 164 | 'message', 165 | ) 166 | }) 167 | 168 | test('Should return an empty diff when no other services exists', async (t) => { 169 | const app = build({ 170 | databaseConnectionUrl: t.context.connectionUrl, 171 | }) 172 | t.teardown(() => app.close()) 173 | 174 | let res = await app.inject({ 175 | method: 'POST', 176 | url: '/schema/push', 177 | payload: { 178 | typeDefs: /* GraphQL */ ` 179 | type Query { 180 | hello: String 181 | } 182 | `, 183 | version: '3', 184 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 185 | serviceName: `${t.context.testPrefix}_foo`, 186 | graphName: `${t.context.graphName}`, 187 | }, 188 | }) 189 | 190 | t.is(res.statusCode, 200) 191 | 192 | res = await app.inject({ 193 | method: 'POST', 194 | url: '/schema/check', 195 | payload: { 196 | typeDefs: /* GraphQL */ ` 197 | type Query { 198 | hello: String 199 | } 200 | `, 201 | serviceName: `${t.context.testPrefix}_foo`, 202 | graphName: `${t.context.graphName}`, 203 | }, 204 | }) 205 | 206 | t.is(res.statusCode, 200) 207 | t.deepEqual( 208 | res.json(), 209 | { 210 | success: true, 211 | data: { 212 | breakingChangeAdded: false, 213 | deprecationAdded: false, 214 | report: [], 215 | }, 216 | }, 217 | 'message', 218 | ) 219 | }) 220 | -------------------------------------------------------------------------------- /src/registry/federation/compose-schema.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { CURRENT_VERSION } from '../../core/constants' 4 | import { 5 | cleanTest, 6 | createTestContext, 7 | createTestPrefix, 8 | TestContext, 9 | trimDoc, 10 | } from '../../core/test-util' 11 | 12 | const test = anyTest as TestInterface 13 | test.before(createTestContext()) 14 | test.beforeEach(createTestPrefix()) 15 | test.after.always('cleanup', cleanTest()) 16 | 17 | test('Should return schema of two services', async (t) => { 18 | const app = build({ 19 | databaseConnectionUrl: t.context.connectionUrl, 20 | }) 21 | t.teardown(() => app.close()) 22 | 23 | let res = await app.inject({ 24 | method: 'POST', 25 | url: '/schema/push', 26 | payload: { 27 | typeDefs: /* GraphQL */ ` 28 | type Query { 29 | hello: String 30 | } 31 | `, 32 | version: '1', 33 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 34 | serviceName: `${t.context.testPrefix}_foo`, 35 | graphName: `${t.context.graphName}`, 36 | }, 37 | }) 38 | t.is(res.statusCode, 200) 39 | res = await app.inject({ 40 | method: 'POST', 41 | url: '/schema/push', 42 | payload: { 43 | typeDefs: /* GraphQL */ ` 44 | type Query { 45 | world: String 46 | } 47 | `, 48 | version: '2', 49 | routingUrl: `http://${t.context.testPrefix}_bar:3000/api/graphql`, 50 | serviceName: `${t.context.testPrefix}_bar`, 51 | graphName: `${t.context.graphName}`, 52 | }, 53 | }) 54 | t.is(res.statusCode, 200) 55 | 56 | res = await app.inject({ 57 | method: 'GET', 58 | url: '/schema/latest', 59 | query: { 60 | graphName: `${t.context.graphName}`, 61 | }, 62 | }) 63 | 64 | t.is(res.statusCode, 200) 65 | 66 | const response = res.json() 67 | 68 | t.true(response.success) 69 | t.is(response.data.length, 2) 70 | 71 | t.truthy(response.data[0].lastUpdatedAt) 72 | t.like(response.data[0], { 73 | serviceName: `${t.context.testPrefix}_bar`, 74 | typeDefs: trimDoc/* GraphQL */ ` 75 | type Query { 76 | world: String 77 | } 78 | `, 79 | version: '2', 80 | }) 81 | 82 | t.truthy(response.data[1].lastUpdatedAt) 83 | t.like(response.data[1], { 84 | serviceName: `${t.context.testPrefix}_foo`, 85 | typeDefs: trimDoc/* GraphQL */ ` 86 | type Query { 87 | hello: String 88 | } 89 | `, 90 | version: '1', 91 | }) 92 | }) 93 | 94 | test('Should return 400 error when graph does not exist', async (t) => { 95 | const app = build({ 96 | databaseConnectionUrl: t.context.connectionUrl, 97 | }) 98 | t.teardown(() => app.close()) 99 | 100 | const res = await app.inject({ 101 | method: 'GET', 102 | url: '/schema/latest', 103 | query: { 104 | graphName: `${t.context.graphName}`, 105 | }, 106 | }) 107 | 108 | t.is(res.statusCode, 400) 109 | 110 | const response = res.json() 111 | 112 | t.false(response.success) 113 | 114 | t.is(response.error, `Graph with name "${t.context.graphName}" does not exist`) 115 | }) 116 | 117 | test('Version "current" has no precedence over the last updated', async (t) => { 118 | const app = build({ 119 | databaseConnectionUrl: t.context.connectionUrl, 120 | }) 121 | t.teardown(() => app.close()) 122 | 123 | let res = await app.inject({ 124 | method: 'POST', 125 | url: '/schema/push', 126 | payload: { 127 | typeDefs: /* GraphQL */ ` 128 | type Query { 129 | hello: String 130 | } 131 | `, 132 | version: CURRENT_VERSION, 133 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 134 | serviceName: `${t.context.testPrefix}_foo`, 135 | graphName: `${t.context.graphName}`, 136 | }, 137 | }) 138 | t.is(res.statusCode, 200) 139 | res = await app.inject({ 140 | method: 'POST', 141 | url: '/schema/push', 142 | payload: { 143 | typeDefs: /* GraphQL */ ` 144 | type Query { 145 | world: String 146 | } 147 | `, 148 | version: '2', 149 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 150 | serviceName: `${t.context.testPrefix}_foo`, 151 | graphName: `${t.context.graphName}`, 152 | }, 153 | }) 154 | t.is(res.statusCode, 200) 155 | 156 | res = await app.inject({ 157 | method: 'GET', 158 | url: '/schema/latest', 159 | query: { 160 | graphName: `${t.context.graphName}`, 161 | }, 162 | }) 163 | 164 | t.is(res.statusCode, 200) 165 | 166 | const response = res.json() 167 | 168 | t.true(response.success) 169 | t.is(response.data.length, 1) 170 | 171 | t.like(response.data[0], { 172 | serviceName: `${t.context.testPrefix}_foo`, 173 | typeDefs: trimDoc/* GraphQL */ ` 174 | type Query { 175 | world: String 176 | } 177 | `, 178 | version: '2', 179 | }) 180 | }) 181 | 182 | test('Should include "routingUrl" of the service', async (t) => { 183 | const app = build({ 184 | databaseConnectionUrl: t.context.connectionUrl, 185 | }) 186 | t.teardown(() => app.close()) 187 | 188 | let res = await app.inject({ 189 | method: 'POST', 190 | url: '/schema/push', 191 | payload: { 192 | typeDefs: /* GraphQL */ ` 193 | type Query { 194 | hello: String 195 | } 196 | `, 197 | version: '1', 198 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 199 | serviceName: `${t.context.testPrefix}_foo`, 200 | graphName: `${t.context.graphName}`, 201 | }, 202 | }) 203 | t.is(res.statusCode, 200) 204 | 205 | res = await app.inject({ 206 | method: 'GET', 207 | url: '/schema/latest', 208 | query: { 209 | graphName: `${t.context.graphName}`, 210 | }, 211 | }) 212 | 213 | t.is(res.statusCode, 200) 214 | 215 | const response = res.json() 216 | 217 | t.true(response.success) 218 | t.is(response.data.length, 1) 219 | 220 | t.like(response.data[0], { 221 | serviceName: `${t.context.testPrefix}_foo`, 222 | typeDefs: trimDoc/* GraphQL */ ` 223 | type Query { 224 | hello: String 225 | } 226 | `, 227 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 228 | version: '1', 229 | }) 230 | }) 231 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Composition stability 2 | 3 | Whenever a schema is pushed or fetched, Graph-Registry ensures that the schema is valid. You can't fetch or produce an invalid registry state. This doesn't imply that you can't break your schema from consumer perspective. Always use a [change report](#creates-a-change-report) to understand your schema update. 4 | 5 | # Terminology 6 | 7 | ## One graph, many services 8 | 9 |
10 | graphql-registry 11 |
12 | 13 | - **Graph:** A graph consists of multiple schemas managed by different services. You can create multiple graphs to build any variant. From consumer perspective, the composed graph state is determined and validated at runtime. 14 | - **Supergraph:** A supergraph describes a static composition of a `graph`. It's compliant with the [Apollo Supergraph specification](https://specs.apollo.dev/join/v0.1/). 15 | - **Schema:** A schema describes the shape of the data graph of a single graphql server. A schema is always associated to one service. 16 | - **Service:** A service represent a unique graph-server in your infrastructure for example `Products`. A service can manage multiple schemas in different versions (e.g `v1`, `v2`, `current`). The `current` version is [special](#register-a-schema). The term `service` reflects the real-world. There must be a federated service which is responsible to manage the schema. 17 | 18 | # API 19 | 20 | ## Schema federation 21 | 22 | ### Get all Graphs 23 | 24 | GET - `/graphs` Returns all registered graphs. 25 | 26 | ### Get latest schemas 27 | 28 | GET - `/schema/latest?graphName=my_graph` Returns the last registered (time-based) schema definition of all services. 29 | 30 | **Query** 31 | 32 | - `graphName`: (string) The name of the graph 33 | 34 | **Notice:** Work always with versions in production. 35 | 36 | ### Register a schema 37 | 38 | POST - `/schema/push` Creates a new graph and schema for a service. If you omit the `version` field the schema is registered as `current` version. `current` is a mutable version that always points to the registered schema. The `routingUrl` is the URL that your gateway uses to communicate with the service in a federation architecture. 39 | 40 | **Idempotent:** yes 41 | 42 | **Notice:** A schema is normalized before it's stored in the database. Whitespaces are stipped. 43 | 44 | **Notice:** The schema isn't validated for breaking-changes. Use a [change report](#creates-a-change-report) to understand the implications of your update. 45 | 46 |
47 | Example Request 48 |

49 | 50 | ```jsonc 51 | { 52 | "typeDefs": "type Query { hello: String }", 53 | "graphName": "my_graph", 54 | "serviceName": "foo", 55 | "version": "1", // optional, uses "current" by default 56 | "routingUrl": "http://products-graphql.svc.cluster.local:4001/graphql" 57 | } 58 | ``` 59 | 60 |

61 |
62 | 63 | ### Get latest schemas by versions 64 | 65 | POST - `/schema/compose` Returns the last registered schema definition of all services based on passed services & their versions. If versions can't be found it fails. You can use the version `current` to fetch the latest schema that was pushed with the version `current`. This is useful for rapid development when you don't want to deal with versioning. For production use immutable versions. 66 | 67 | **Idempotent:** yes 68 | 69 | **Notice:** The version `current` doesn't represent the latest schema. It's handled as a mutable version. 70 | 71 |
72 | Example Request 73 |

74 | 75 | ```jsonc 76 | { 77 | "graphName": "my_graph", 78 | "services": [{ "name": "foo", "version": "1" }] 79 | } 80 | ``` 81 | 82 |

83 |
84 | 85 | ### Get supergraph schema 86 | 87 | POST - `/schema/supergraph` Returns the supergraph schema definition of all registered services. The supergraph is composed of the latest schema version of a service. 88 | 89 | **Idempotent:** yes 90 | 91 |
92 | Example Request 93 |

94 | 95 | ```jsonc 96 | { 97 | "graphName": "my_graph" 98 | } 99 | ``` 100 | 101 |

102 |
103 | 104 | ### Deactivate a schema 105 | 106 | PUT - `/schema/deactivate` Deactivates a schema by id. The schema will no longer be part of any result. You can re-activate it by registering. 107 | 108 | **Idempotent:** yes 109 | 110 |
111 | Example Request 112 |

113 | 114 | ```jsonc 115 | { 116 | "schemaId": "916348424" 117 | } 118 | ``` 119 | 120 |

121 |
122 | 123 | --- 124 | 125 | ## Validation 126 | 127 | ### Creates a change report 128 | 129 | POST - `/schema/check` Returns the schema report between provided and latest schema. It can detect breaking, dangerous and safe changes. This should be executed before a new schema is pushed. 130 | 131 | **Idempotent:** yes 132 | 133 |
134 | Example Request 135 |

136 | 137 | ```json 138 | { 139 | "graphName": "my_graph", 140 | "typeDefs": "type Query { hello: String }", 141 | "serviceName": "foo" 142 | } 143 | ``` 144 | 145 |

146 |
147 | 148 | ### Creates a schema coverage report 149 | 150 | POST - `/schema/coverage` Returns the schema coverage between provided documents and latest schema or service versions. It returns a detailed reports about type and field hits. 151 | 152 | **Idempotent:** yes 153 | 154 |
155 | Example Request 156 |

157 | 158 | ```json 159 | { 160 | "graphName": "my_graph", 161 | "documents": [{ "name": "foo.graphql", "source": "query { hello }" }], 162 | "services": [{ "name": "foo", "version": "1" }] // optional 163 | } 164 | ``` 165 | 166 |

167 |
168 | 169 | ### Validate your schema 170 | 171 | POST - `/schema/validate` Validate schema between provided and latest schema. It only verify if the schema can be composed. 172 | 173 | **Idempotent:** yes 174 | 175 |
176 | Example Request 177 |

178 | 179 | ```json 180 | { 181 | "graphName": "my_graph", 182 | "typeDefs": "type Query { hello: String }", 183 | "serviceName": "foo" 184 | } 185 | ``` 186 | 187 |

188 |
189 | 190 | ### Validating client operations 191 | 192 | POST - `/document/validate` Confirm that all client operations are supported by the latest schema. 193 | 194 | **Idempotent:** yes 195 | 196 |
197 | Example Request 198 |

199 | 200 | ```json 201 | { 202 | "graphName": "my_graph", 203 | "documents": ["query { hello }"] 204 | } 205 | ``` 206 | 207 |

208 |
209 | 210 | --- 211 | 212 | ## Monitoring / Maintanance 213 | 214 | ### Remove all schemas except the most (N) recent 215 | 216 | POST - `/schema/garbage_collect` Removes all schemas except the most recent N of every service. Returns the count removed schemas and versions. This could be called by a cron. 217 | 218 | **Idempotent:** no 219 | 220 |
221 | Example Request 222 |

223 | 224 | ```jsonc 225 | { 226 | "num_schemas_keep": 10 // minimum is 10 227 | } 228 | ``` 229 | 230 |

231 |
232 | 233 | ### Check if registry is reachable 234 | 235 | GET - `/health` healthcheck endpoint. 236 | -------------------------------------------------------------------------------- /src/registry/federation/register-schema.ts: -------------------------------------------------------------------------------- 1 | import S from 'fluent-json-schema' 2 | import { SchemaManager } from '../../core/manager/SchemaManager' 3 | import { composeAndValidateSchema } from '../../core/federation' 4 | import { SchemaResponseModel, SuccessResponse } from '../../core/types' 5 | import { FastifyInstance, FastifySchema } from 'fastify' 6 | import { 7 | DuplicateServiceUrlError, 8 | SchemaCompositionError, 9 | SchemaVersionLookupError, 10 | } from '../../core/errors' 11 | import { checkUserServiceScope } from '../../core/hook-handler/user-scope.prevalidation' 12 | import ServiceRepository from '../../core/repositories/ServiceRepository' 13 | import GraphRepository from '../../core/repositories/GraphRepository' 14 | import SchemaRepository from '../../core/repositories/SchemaRepository' 15 | import SchemaTagRepository from '../../core/repositories/SchemaTagRepository' 16 | import { SchemaTagDBModel } from '../../core/models/schemaTagModel' 17 | import { CURRENT_VERSION } from '../../core/constants' 18 | import { normalizeSchema } from '../../core/graphql-utils' 19 | import { 20 | dateTime, 21 | graphName, 22 | routingUrl, 23 | schemaId, 24 | serviceName, 25 | typeDefs, 26 | version, 27 | } from '../../core/shared-schemas' 28 | 29 | export interface RequestContext { 30 | Body: { 31 | serviceName: string 32 | version: string 33 | typeDefs: string 34 | graphName: string 35 | routingUrl: string 36 | } 37 | } 38 | 39 | export const schema: FastifySchema = { 40 | response: { 41 | '2xx': S.object() 42 | .additionalProperties(false) 43 | .required(['success', 'data']) 44 | .prop('success', S.boolean()) 45 | .prop( 46 | 'data', 47 | S.object() 48 | .required(['version', 'typeDefs', 'serviceName', 'schemaId']) 49 | .prop('schemaId', schemaId) 50 | .prop('version', version) 51 | .prop('typeDefs', typeDefs) 52 | .prop('serviceName', serviceName) 53 | .prop('routingUrl', routingUrl) 54 | .prop('lastUpdatedAt', dateTime), 55 | ), 56 | }, 57 | body: S.object() 58 | .additionalProperties(false) 59 | .required(['version', 'typeDefs', 'serviceName', 'graphName', 'routingUrl']) 60 | .prop('graphName', graphName) 61 | .prop('version', version.default(CURRENT_VERSION)) 62 | .prop('typeDefs', typeDefs) 63 | .prop('serviceName', serviceName) 64 | .prop('routingUrl', routingUrl), 65 | } 66 | 67 | export default function registerSchema(fastify: FastifyInstance) { 68 | fastify.post( 69 | '/schema/push', 70 | { schema, preValidation: checkUserServiceScope }, 71 | async (req, res) => { 72 | return fastify.knex.transaction(async function (trx) { 73 | const serviceRepository = new ServiceRepository(trx) 74 | const schemaRepository = new SchemaRepository(trx) 75 | const graphRepository = new GraphRepository(trx) 76 | const schemaTagRepository = new SchemaTagRepository(trx) 77 | 78 | const serviceModels = await serviceRepository.findManyExceptWithName( 79 | { 80 | graphName: req.body.graphName, 81 | }, 82 | req.body.serviceName, 83 | ) 84 | 85 | const allLatestServices = serviceModels.map((s) => ({ name: s.name })) 86 | const schmemaService = new SchemaManager(serviceRepository, schemaRepository) 87 | const { schemas, error: findError } = await schmemaService.findByServiceVersions( 88 | req.body.graphName, 89 | allLatestServices, 90 | ) 91 | 92 | if (findError) { 93 | throw SchemaVersionLookupError(findError.message) 94 | } 95 | 96 | const serviceSchemas = schemas.map((s) => ({ 97 | name: s.serviceName, 98 | typeDefs: s.typeDefs, 99 | url: s.routingUrl, 100 | })) 101 | // Add the new schema to validate it against the current registry state before creating. 102 | serviceSchemas.push({ 103 | name: req.body.serviceName, 104 | typeDefs: req.body.typeDefs, 105 | url: req.body.routingUrl, 106 | }) 107 | 108 | const { error: schemaError } = composeAndValidateSchema(serviceSchemas) 109 | 110 | if (schemaError) { 111 | throw SchemaCompositionError(schemaError) 112 | } 113 | 114 | /** 115 | * Create new graph 116 | */ 117 | 118 | let graph = await graphRepository.findFirst({ 119 | name: req.body.graphName, 120 | }) 121 | 122 | if (!graph) { 123 | graph = await graphRepository.create({ name: req.body.graphName }) 124 | } 125 | /** 126 | * Create new service 127 | */ 128 | 129 | let service = await serviceRepository.findFirst({ 130 | graphName: req.body.graphName, 131 | name: req.body.serviceName, 132 | }) 133 | 134 | const serviceByRoutingUrl = await serviceRepository.findByRoutingUrl({ 135 | graphName: req.body.graphName, 136 | routingUrl: req.body.routingUrl, 137 | }) 138 | 139 | if (!service) { 140 | if (serviceByRoutingUrl) { 141 | throw DuplicateServiceUrlError(serviceByRoutingUrl.name, serviceByRoutingUrl.routingUrl) 142 | } 143 | service = await serviceRepository.create({ 144 | name: req.body.serviceName, 145 | graphId: graph.id, 146 | routingUrl: req.body.routingUrl, 147 | }) 148 | } else if (req.body.routingUrl !== service.routingUrl) { 149 | if (serviceByRoutingUrl) { 150 | throw DuplicateServiceUrlError(serviceByRoutingUrl.name, serviceByRoutingUrl.routingUrl) 151 | } 152 | const updatedService = await serviceRepository.updateOne( 153 | { 154 | routingUrl: req.body.routingUrl, 155 | }, 156 | { 157 | id: service.id, 158 | }, 159 | ) 160 | service = updatedService! 161 | } 162 | 163 | const mormalizedTypeDefs = normalizeSchema(req.body.typeDefs) 164 | 165 | /** 166 | * Create new schema 167 | */ 168 | 169 | let schema = await schemaRepository.findFirst({ 170 | graphName: req.body.graphName, 171 | serviceName: req.body.serviceName, 172 | typeDefs: mormalizedTypeDefs, 173 | }) 174 | 175 | if (!schema) { 176 | schema = await schemaRepository.create({ 177 | graphId: graph.id, 178 | serviceId: service.id, 179 | typeDefs: mormalizedTypeDefs, 180 | }) 181 | } else { 182 | const updatedSchema = await schemaRepository.updateById(schema.id, { 183 | updatedAt: new Date(), 184 | }) 185 | schema = updatedSchema! 186 | } 187 | 188 | let schemaTag: SchemaTagDBModel | undefined 189 | /** 190 | * "current" always points to the latest registered schema of the service 191 | */ 192 | if (req.body.version === CURRENT_VERSION) { 193 | schemaTag = await schemaTagRepository.findFirst({ 194 | version: req.body.version, 195 | serviceId: service.id, 196 | }) 197 | if (schemaTag) { 198 | await schemaTagRepository.update( 199 | { 200 | schemaId: schema.id, 201 | }, 202 | { 203 | serviceId: service.id, 204 | version: req.body.version, 205 | }, 206 | ) 207 | } 208 | } else { 209 | schemaTag = await schemaTagRepository.findFirst({ 210 | version: req.body.version, 211 | schemaId: schema.id, 212 | serviceId: service.id, 213 | }) 214 | } 215 | 216 | /** 217 | * Create new schema tag 218 | */ 219 | if (!schemaTag) { 220 | schemaTag = await schemaTagRepository.create({ 221 | serviceId: service.id, 222 | version: req.body.version, 223 | schemaId: schema.id, 224 | }) 225 | } 226 | 227 | const responseBody: SuccessResponse = { 228 | success: true, 229 | data: { 230 | schemaId: schema.id, 231 | serviceName: req.body.serviceName, 232 | typeDefs: schema.typeDefs, 233 | version: schemaTag.version, 234 | routingUrl: service.routingUrl, 235 | lastUpdatedAt: schema.updatedAt, 236 | }, 237 | } 238 | 239 | return responseBody 240 | }) 241 | }, 242 | ) 243 | } 244 | -------------------------------------------------------------------------------- /Insomnia.json: -------------------------------------------------------------------------------- 1 | {"_type":"export","__export_format":4,"__export_date":"2021-05-25T20:57:35.968Z","__export_source":"insomnia.desktop.app:v2021.3.0","resources":[{"_id":"req_ae07bb97e41c45488fe4e098e9fcd392","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1621855704055,"created":1620404658927,"url":"{{ _.baseUrl }}/schema/push","name":"Register Schema","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"typeDefs\": \"type Query { hello: String }\",\n \"graphName\": \"my_graph\",\n\t\"routingUrl\": \"http://localhost:4005/graphql\",\n \"serviceName\": \"foo\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_cae5a5a7c1df4fbbb5f95747ac6de78d"}],"authentication":{},"metaSortKey":400,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620405269116,"created":1620405194749,"name":"Federation","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":0,"_type":"request_group"},{"_id":"wrk_c5b177f049bf44f7aefeb315af5594bd","parentId":null,"modified":1620404599265,"created":1620404599265,"name":"Insomnia","description":"","scope":"collection","_type":"workspace"},{"_id":"req_336ebdc5fba642858d5d978700e65df1","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1620405391499,"created":1620404757674,"url":"{{ _.baseUrl }}/schema/compose","name":"Get latest schemas by versions","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"graphName\": \"my_graph\",\n \"services\": [{ \"name\": \"foo\", \"version\": \"1\" }]\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_aa776b984d75400d8e53ab6412dc8ae5"}],"authentication":{},"metaSortKey":300,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f5c560be99cf4216a9d6ae0cf7edb858","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1621795942881,"created":1620404802379,"url":"{{ _.baseUrl }}/schema/deactivate","name":"Deactivate schema","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n \"schemaId\": \"916348424\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_54598bee89b9433ea71b152980c68562"}],"authentication":{},"metaSortKey":0,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_26b0c1787982455ab1a8486a57182db8","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1620405355948,"created":1620405132600,"url":"{{ _.baseUrl }}/graphs","name":"Get all Graphs","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":100,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_3cd0a6406eca4f2d8d0103c32212c506","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1620405345639,"created":1620405164443,"url":"{{ _.baseUrl }}/schema/latest?graphName=my_graph","name":"Get latest schemas","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":200,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_799bd4ed1e8e41779a5f64c4a853acca","parentId":"fld_cb00a6e13e6840f4b4e1a0c5c2f63f82","modified":1621770316312,"created":1621770230066,"url":"{{ _.baseUrl }}/schema/supergraph","name":"Get supergraph","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"graphName\": \"my_graph\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_134c2354261e4246a80e3051caf2447e"}],"authentication":{},"metaSortKey":-1621770230066,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ad711a8ef50b4143a7a624684ab711bd","parentId":"fld_ee0c7779dcc54e09ab1a0f1715909023","modified":1621795887968,"created":1620404900201,"url":"{{ _.baseUrl }}/schema/check","name":"Schema check","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"graphName\": \"my_graph\",\n \"typeDefs\": \"type Query { foo: Int }\",\n \"serviceName\": \"foo\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_83fe9b94608343838cd283d0c2784105"}],"authentication":{},"metaSortKey":0,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_ee0c7779dcc54e09ab1a0f1715909023","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620405269116,"created":1620405215454,"name":"Validation","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":200,"_type":"request_group"},{"_id":"req_bcab07d7f22c4321b89380f4e0c5a26e","parentId":"fld_ee0c7779dcc54e09ab1a0f1715909023","modified":1621795925640,"created":1620405020069,"url":"{{ _.baseUrl }}/schema/validate","name":"Schema Composition","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"graphName\": \"my_graph\",\n \"typeDefs\": \"type Query { hello: String }\",\n \"serviceName\": \"foo\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_b58858e1c31c4765ac9d9226bcbc8592"}],"authentication":{},"metaSortKey":100,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e16e31d5573544289ee1a383bddff9fa","parentId":"fld_ee0c7779dcc54e09ab1a0f1715909023","modified":1620501890286,"created":1620487663119,"url":"{{ _.baseUrl }}/document/validate","name":"Document validation","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"graphName\": \"my_graph\",\n\t\"document\": \"query { hello }\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_ea979a8ec65d4a3ab22f876e2d372988"}],"authentication":{},"metaSortKey":-50,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6164047c22984d0d83c7f62002bbd30a","parentId":"fld_ee0c7779dcc54e09ab1a0f1715909023","modified":1621976171168,"created":1621975017880,"url":"{{ _.baseUrl }}/schema/coverage","name":"Schema coverage","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"graphName\": \"my_graph\",\n\t\"documents\": [{\"name\": \"foo\", \"source\": \"query { hello }\"}]\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_ea979a8ec65d4a3ab22f876e2d372988"}],"authentication":{},"metaSortKey":-25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_627080f299434b4ca2ab995cc688c367","parentId":"fld_12e49a858e304effbd88e3809391438c","modified":1620502909859,"created":1620405064146,"url":"{{ _.baseUrl }}/schema/garbage_collect","name":"Remove all schemas except the most (N) recent","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"numSchemasKeep\": 10\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4b3a277d831748888d0d4aeb8178cc7f"}],"authentication":{},"metaSortKey":100,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_12e49a858e304effbd88e3809391438c","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620405269116,"created":1620405250707,"name":"Maintainance","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":100,"_type":"request_group"},{"_id":"req_90a4b689d5d94521be8dcf99c2d010a1","parentId":"fld_12e49a858e304effbd88e3809391438c","modified":1620405402062,"created":1620405098480,"url":"{{ _.baseUrl }}/health","name":"Health","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":0,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_8e8bb9d79ca63367faee7a2d56149069e14cc521","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620405374019,"created":1620404599292,"name":"Base Environment","data":{"baseUrl":"http://127.0.0.1:3000"},"dataPropertyOrder":{"&":["baseUrl"]},"color":null,"isPrivate":false,"metaSortKey":1620404599292,"_type":"environment"},{"_id":"jar_8e8bb9d79ca63367faee7a2d56149069e14cc521","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620404599294,"created":1620404599294,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_8abc133306f14e4da7b7feff11c83a08","parentId":"wrk_c5b177f049bf44f7aefeb315af5594bd","modified":1620404599266,"created":1620404599266,"fileName":"Insomnia","contents":"","contentType":"yaml","_type":"api_spec"}]} -------------------------------------------------------------------------------- /src/registry/federation/compose-schema-versions.test.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import build from '../../build-server' 3 | import { CURRENT_VERSION } from '../../core/constants' 4 | import { 5 | cleanTest, 6 | createTestContext, 7 | createTestPrefix, 8 | TestContext, 9 | trimDoc, 10 | } from '../../core/test-util' 11 | 12 | const test = anyTest as TestInterface 13 | test.before(createTestContext()) 14 | test.beforeEach(createTestPrefix()) 15 | test.after.always('cleanup', cleanTest()) 16 | 17 | test('Should return schema of two services', async (t) => { 18 | const app = build({ 19 | databaseConnectionUrl: t.context.connectionUrl, 20 | }) 21 | t.teardown(() => app.close()) 22 | 23 | let res = await app.inject({ 24 | method: 'POST', 25 | url: '/schema/push', 26 | payload: { 27 | typeDefs: /* GraphQL */ ` 28 | type Query { 29 | hello: String 30 | } 31 | `, 32 | version: '1', 33 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 34 | serviceName: `${t.context.testPrefix}_foo`, 35 | graphName: `${t.context.graphName}`, 36 | }, 37 | }) 38 | t.is(res.statusCode, 200) 39 | 40 | res = await app.inject({ 41 | method: 'POST', 42 | url: '/schema/push', 43 | payload: { 44 | typeDefs: /* GraphQL */ ` 45 | type Query { 46 | hello: String 47 | } 48 | `, 49 | version: '2', 50 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 51 | serviceName: `${t.context.testPrefix}_foo`, 52 | graphName: `${t.context.graphName}`, 53 | }, 54 | }) 55 | t.is(res.statusCode, 200) 56 | 57 | res = await app.inject({ 58 | method: 'POST', 59 | url: '/schema/push', 60 | payload: { 61 | typeDefs: /* GraphQL */ ` 62 | type Query { 63 | world: String 64 | } 65 | `, 66 | version: '1', 67 | routingUrl: `http://${t.context.testPrefix}_bar:3000/api/graphql`, 68 | serviceName: `${t.context.testPrefix}_bar`, 69 | graphName: `${t.context.graphName}`, 70 | }, 71 | }) 72 | t.is(res.statusCode, 200) 73 | 74 | res = await app.inject({ 75 | method: 'POST', 76 | url: '/schema/push', 77 | payload: { 78 | typeDefs: /* GraphQL */ ` 79 | type Query { 80 | world: String 81 | } 82 | `, 83 | version: '2', 84 | routingUrl: `http://${t.context.testPrefix}_bar:3000/api/graphql`, 85 | serviceName: `${t.context.testPrefix}_bar`, 86 | graphName: `${t.context.graphName}`, 87 | }, 88 | }) 89 | t.is(res.statusCode, 200) 90 | 91 | res = await app.inject({ 92 | method: 'POST', 93 | url: '/schema/compose', 94 | payload: { 95 | graphName: `${t.context.graphName}`, 96 | services: [ 97 | { 98 | name: `${t.context.testPrefix}_foo`, 99 | version: '2', 100 | }, 101 | { 102 | name: `${t.context.testPrefix}_bar`, 103 | version: '2', 104 | }, 105 | ], 106 | }, 107 | }) 108 | 109 | t.is(res.statusCode, 200) 110 | 111 | const response = res.json() 112 | 113 | t.true(response.success) 114 | t.is(response.data.length, 2) 115 | 116 | t.truthy(response.data[0].lastUpdatedAt) 117 | t.like(response.data[0], { 118 | serviceName: `${t.context.testPrefix}_foo`, 119 | typeDefs: trimDoc/* GraphQL */ ` 120 | type Query { 121 | hello: String 122 | } 123 | `, 124 | version: '2', 125 | }) 126 | 127 | t.truthy(response.data[1].lastUpdatedAt) 128 | t.like(response.data[1], { 129 | serviceName: `${t.context.testPrefix}_bar`, 130 | typeDefs: trimDoc/* GraphQL */ ` 131 | type Query { 132 | world: String 133 | } 134 | `, 135 | version: '2', 136 | }) 137 | }) 138 | 139 | test('Should return validation error when no version was specified', async (t) => { 140 | const app = build({ 141 | databaseConnectionUrl: t.context.connectionUrl, 142 | }) 143 | t.teardown(() => app.close()) 144 | 145 | const res = await app.inject({ 146 | method: 'POST', 147 | url: '/schema/compose', 148 | payload: { 149 | graphName: `${t.context.graphName}`, 150 | services: [ 151 | { 152 | name: `${t.context.testPrefix}_foo`, 153 | }, 154 | ], 155 | }, 156 | }) 157 | 158 | t.is(res.statusCode, 400) 159 | 160 | const response = res.json() 161 | 162 | t.false(response.success) 163 | 164 | t.is(response.error, "body.services[0] should have required property 'version'") 165 | }) 166 | 167 | test('Should return 400 error when graph does not exist', async (t) => { 168 | const app = build({ 169 | databaseConnectionUrl: t.context.connectionUrl, 170 | }) 171 | t.teardown(() => app.close()) 172 | 173 | const res = await app.inject({ 174 | method: 'POST', 175 | url: '/schema/compose', 176 | payload: { 177 | graphName: `${t.context.graphName}`, 178 | services: [ 179 | { 180 | name: `foo`, 181 | version: '1', 182 | }, 183 | ], 184 | }, 185 | }) 186 | 187 | t.is(res.statusCode, 400) 188 | 189 | const response = res.json() 190 | 191 | t.false(response.success) 192 | 193 | t.is(response.error, `Graph with name "${t.context.graphName}" does not exist`) 194 | }) 195 | 196 | test('Should return 404 when schema in version could not be found', async (t) => { 197 | const app = build({ 198 | databaseConnectionUrl: t.context.connectionUrl, 199 | }) 200 | t.teardown(() => app.close()) 201 | 202 | let res = await app.inject({ 203 | method: 'POST', 204 | url: '/schema/push', 205 | payload: { 206 | typeDefs: /* GraphQL */ ` 207 | type Query { 208 | hello: String 209 | } 210 | `, 211 | version: '1', 212 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 213 | serviceName: `${t.context.testPrefix}_foo`, 214 | graphName: `${t.context.graphName}`, 215 | }, 216 | }) 217 | t.is(res.statusCode, 200) 218 | 219 | res = await app.inject({ 220 | method: 'POST', 221 | url: '/schema/compose', 222 | payload: { 223 | graphName: `${t.context.graphName}`, 224 | services: [ 225 | { 226 | name: `${t.context.testPrefix}_foo`, 227 | version: '2', 228 | }, 229 | ], 230 | }, 231 | }) 232 | 233 | t.is(res.statusCode, 400) 234 | 235 | t.deepEqual( 236 | res.json(), 237 | { 238 | error: `In graph "${t.context.graphName}", service "${t.context.testPrefix}_foo" has no schema in version "2" registered`, 239 | success: false, 240 | }, 241 | 'response payload match', 242 | ) 243 | }) 244 | 245 | test('Should return 400 when schema in specified version was deactivated', async (t) => { 246 | const app = build({ 247 | databaseConnectionUrl: t.context.connectionUrl, 248 | }) 249 | t.teardown(() => app.close()) 250 | 251 | let res = await app.inject({ 252 | method: 'POST', 253 | url: '/schema/push', 254 | payload: { 255 | typeDefs: /* GraphQL */ ` 256 | type Query { 257 | hello: String 258 | } 259 | `, 260 | version: '1', 261 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 262 | serviceName: `${t.context.testPrefix}_foo`, 263 | graphName: `${t.context.graphName}`, 264 | }, 265 | }) 266 | t.is(res.statusCode, 200) 267 | 268 | const schemaId = res.json().data.schemaId 269 | 270 | res = await app.inject({ 271 | method: 'PUT', 272 | url: '/schema/deactivate', 273 | payload: { 274 | schemaId, 275 | graphName: `${t.context.graphName}`, 276 | }, 277 | }) 278 | 279 | t.is(res.statusCode, 200) 280 | 281 | res = await app.inject({ 282 | method: 'POST', 283 | url: '/schema/compose', 284 | payload: { 285 | graphName: `${t.context.graphName}`, 286 | services: [ 287 | { 288 | name: `${t.context.testPrefix}_foo`, 289 | version: '1', 290 | }, 291 | ], 292 | }, 293 | }) 294 | 295 | t.is(res.statusCode, 400) 296 | 297 | t.deepEqual( 298 | res.json(), 299 | { 300 | error: `In graph "${t.context.graphName}", service "${t.context.testPrefix}_foo" has no schema in version "1" registered`, 301 | success: false, 302 | }, 303 | 'response payload match', 304 | ) 305 | }) 306 | 307 | test('Version "current" should always return the latest (not versioned) registered schema version', async (t) => { 308 | const app = build({ 309 | databaseConnectionUrl: t.context.connectionUrl, 310 | }) 311 | t.teardown(() => app.close()) 312 | 313 | let res = await app.inject({ 314 | method: 'POST', 315 | url: '/schema/push', 316 | payload: { 317 | typeDefs: /* GraphQL */ ` 318 | type Query { 319 | hello: String 320 | } 321 | `, 322 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 323 | serviceName: `${t.context.testPrefix}_foo`, 324 | graphName: `${t.context.graphName}`, 325 | }, 326 | }) 327 | t.is(res.statusCode, 200) 328 | 329 | const firstSchema = res.json() 330 | 331 | res = await app.inject({ 332 | method: 'POST', 333 | url: '/schema/push', 334 | payload: { 335 | typeDefs: /* GraphQL */ ` 336 | type Query { 337 | world: String 338 | } 339 | `, 340 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 341 | serviceName: `${t.context.testPrefix}_foo`, 342 | graphName: `${t.context.graphName}`, 343 | }, 344 | }) 345 | t.is(res.statusCode, 200) 346 | 347 | const secondSchema = res.json() 348 | 349 | res = await app.inject({ 350 | method: 'POST', 351 | url: '/schema/push', 352 | payload: { 353 | typeDefs: /* GraphQL */ ` 354 | type Query { 355 | world: String 356 | } 357 | `, 358 | serviceName: `${t.context.testPrefix}_foo`, 359 | version: '1', 360 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 361 | graphName: `${t.context.graphName}`, 362 | }, 363 | }) 364 | t.is(res.statusCode, 200) 365 | 366 | t.is(firstSchema.version, secondSchema.version) 367 | 368 | res = await app.inject({ 369 | method: 'POST', 370 | url: '/schema/compose', 371 | payload: { 372 | graphName: `${t.context.graphName}`, 373 | services: [ 374 | { 375 | name: `${t.context.testPrefix}_foo`, 376 | version: CURRENT_VERSION, 377 | }, 378 | ], 379 | }, 380 | }) 381 | 382 | const response = res.json() 383 | 384 | t.true(response.success) 385 | t.is(response.data.length, 1) 386 | 387 | t.like(response.data[0], { 388 | serviceName: `${t.context.testPrefix}_foo`, 389 | typeDefs: trimDoc/* GraphQL */ ` 390 | type Query { 391 | world: String 392 | } 393 | `, 394 | version: CURRENT_VERSION, 395 | }) 396 | }) 397 | 398 | test('Should include "routingUrl" of the service', async (t) => { 399 | const app = build({ 400 | databaseConnectionUrl: t.context.connectionUrl, 401 | }) 402 | t.teardown(() => app.close()) 403 | 404 | let res = await app.inject({ 405 | method: 'POST', 406 | url: '/schema/push', 407 | payload: { 408 | typeDefs: trimDoc/* GraphQL */ ` 409 | type Query { 410 | hello: String 411 | } 412 | `, 413 | version: '1', 414 | serviceName: `${t.context.testPrefix}_foo`, 415 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 416 | graphName: `${t.context.graphName}`, 417 | }, 418 | }) 419 | t.is(res.statusCode, 200) 420 | 421 | res = await app.inject({ 422 | method: 'POST', 423 | url: '/schema/compose', 424 | payload: { 425 | graphName: `${t.context.graphName}`, 426 | services: [ 427 | { 428 | name: `${t.context.testPrefix}_foo`, 429 | version: '1', 430 | }, 431 | ], 432 | }, 433 | }) 434 | 435 | t.is(res.statusCode, 200) 436 | 437 | const response = res.json() 438 | 439 | t.true(response.success) 440 | t.is(response.data.length, 1) 441 | 442 | t.like(response.data[0], { 443 | serviceName: `${t.context.testPrefix}_foo`, 444 | typeDefs: trimDoc/* GraphQL */ ` 445 | type Query { 446 | hello: String 447 | } 448 | `, 449 | routingUrl: `http://${t.context.testPrefix}_foo:3000/api/graphql`, 450 | version: '1', 451 | }) 452 | }) 453 | --------------------------------------------------------------------------------