├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.js ├── .vscode └── launch.json ├── LICENSE.md ├── README.md ├── __tests__ ├── __fixtures__ │ └── federatedServiceExtendingUser.ts ├── __snapshots__ │ ├── integration.test.ts.snap │ └── schema.test.ts.snap ├── integration.test.ts ├── schema.test.ts ├── setupSchema.sql └── tsconfig.json ├── jest.config.js ├── package.json ├── scripts └── test ├── src ├── AST.ts ├── index.ts └── printFederatedSchema.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended", 6 | ], 7 | plugins: [ 8 | "jest", 9 | "@typescript-eslint", 10 | "prettier", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | sourceType: "module", 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | env: { 20 | node: true, 21 | jest: true, 22 | es6: true, 23 | }, 24 | rules: { 25 | "@typescript-eslint/no-unused-vars": [ 26 | "error", 27 | { 28 | argsIgnorePattern: "^_", 29 | varsIgnorePattern: "^_", 30 | args: "after-used", 31 | ignoreRestSiblings: true, 32 | }, 33 | ], 34 | 35 | "no-unused-expressions": [ 36 | "error", 37 | { 38 | allowTernary: true, 39 | }, 40 | ], 41 | "no-confusing-arrow": 0, 42 | "no-else-return": 0, 43 | "no-return-assign": [2, "except-parens"], 44 | "no-underscore-dangle": 0, 45 | "arrow-body-style": 0, 46 | "no-nested-ternary": 0, 47 | camelcase: 0, 48 | "prefer-arrow-callback": [ 49 | "error", 50 | { 51 | allowNamedFunctions: true, 52 | }, 53 | ], 54 | "class-methods-use-this": 0, 55 | "no-restricted-syntax": 0, 56 | "no-param-reassign": [ 57 | "error", 58 | { 59 | props: false, 60 | }, 61 | ], 62 | 63 | "jest/no-focused-tests": 2, 64 | "jest/no-identical-title": 2, 65 | 66 | "import/no-extraneous-dependencies": 0, 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CI: true 7 | PGUSER: postgres 8 | PGPASSWORD: postgres 9 | PGHOST: "127.0.0.1" 10 | PGPORT: 5432 11 | TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres 12 | TERM: xterm 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-18.04 17 | 18 | strategy: 19 | matrix: 20 | node-version: [12.x, 14.x] 21 | postgres-version: [9.4, 10, 11, 12] 22 | 23 | services: 24 | postgres: 25 | image: postgres:${{ matrix.postgres-version }} 26 | env: 27 | POSTGRES_USER: postgres 28 | POSTGRES_PASSWORD: postgres 29 | POSTGRES_DB: postgres 30 | ports: 31 | - "0.0.0.0:5432:5432" 32 | # needed because the postgres container does not provide a healthcheck 33 | options: 34 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 35 | --health-retries 5 36 | 37 | steps: 38 | - uses: actions/checkout@v1 39 | - name: Use Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v1 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | - run: yarn --frozen-lockfile 44 | - run: yarn test 45 | env: 46 | PGVERSION: ${{ matrix.postgres-version }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | build 63 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | }; 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "request": "launch", 6 | "name": "debug tests", 7 | "program": "${workspaceFolder}/node_modules/.bin/jest", 8 | "args": ["--runInBand"], 9 | "envFile": "${workspaceFolder}/.env" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © 2019 Benjie Gillam 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the “Software”), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UNMAINTAINED 2 | 3 | This plugin is currently unmaintained whilst we focus on development of 4 | PostGraphile version 5. There are community forks that you may wish to check 5 | out: 6 | 7 | - [mgagliardo91/postgraphile-federation-plugin](https://github.com/mgagliardo91/postgraphile-federation-plugin) 8 | 9 | To add your fork to the list (or describe what sets it apart) please send a 10 | PR to this file. 11 | 12 | # @graphile/federation 13 | 14 | Apollo federation support for PostGraphile (or any Graphile Engine schema). 15 | 16 | ## Installation 17 | 18 | ``` 19 | yarn add postgraphile @graphile/federation 20 | ``` 21 | 22 | ## CLI usage 23 | 24 | ``` 25 | postgraphile --append-plugins @graphile/federation 26 | ``` 27 | 28 | ## Library usage 29 | 30 | ```js 31 | const express = require("express"); 32 | const { postgraphile } = require("postgraphile"); 33 | const { default: FederationPlugin } = require("@graphile/federation"); 34 | 35 | const app = express(); 36 | app.use( 37 | postgraphile(process.env.DATABASE_URL, "public", { 38 | appendPlugins: [FederationPlugin], 39 | }) 40 | ); 41 | app.listen(process.env.PORT || 3000); 42 | ``` 43 | 44 | ## How? 45 | 46 | This plugin exposes the [Global Object Identification 47 | Specification](https://facebook.github.io/relay/graphql/objectidentification.htm) 48 | (i.e. `Node` interface) in a way that's compatible with Apollo Federation. 49 | 50 | Requires PostGraphile v4.4.2-rc.0+ and a maintained LTS version of Node. 51 | 52 | ## Do you need this? 53 | 54 | Only use this if you're planning to have your API consumed by Apollo 55 | Federation; exposing these redundant interfaces to regular users may be 56 | confusing. 57 | 58 | ## Status 59 | 60 | Proof of concept. No tests, use at your own risk! Pull requests very welcome. 61 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/federatedServiceExtendingUser.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphile-utils"; 2 | 3 | import { ApolloServer } from "apollo-server"; 4 | import { buildFederatedSchema } from "@apollo/federation"; 5 | 6 | const typeDefs = gql` 7 | type Query { 8 | empty: ID 9 | } 10 | 11 | extend type User @key(fields: "nodeId") { 12 | nodeId: ID! @external 13 | firstName: String! @external 14 | lastName: String! @external 15 | fullName: String! @requires(fields: "firstName lastName") 16 | } 17 | `; 18 | 19 | const resolvers = { 20 | User: { 21 | fullName({ firstName, lastName }: { firstName: string; lastName: string }) { 22 | return `${firstName} ${lastName}`; 23 | }, 24 | }, 25 | }; 26 | 27 | export function startFederatedServiceExtendingUser() { 28 | return new ApolloServer({ 29 | schema: buildFederatedSchema([ 30 | { 31 | typeDefs, 32 | resolvers, 33 | }, 34 | ]), 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/integration.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`federated service: federated schema 1`] = ` 4 | type Email implements Node { 5 | """ 6 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 7 | """ 8 | nodeId: ID! 9 | id: Int! 10 | email: String! 11 | 12 | """Reads and enables pagination through a set of \`UsersEmail\`.""" 13 | usersEmailsByEmailIdList( 14 | """Only read the first \`n\` values of the set.""" 15 | first: Int 16 | 17 | """Skip the first \`n\` values.""" 18 | offset: Int 19 | 20 | """The method to use when ordering \`UsersEmail\`.""" 21 | orderBy: [UsersEmailsOrderBy!] 22 | 23 | """ 24 | A condition to be used in determining which values should be returned by the collection. 25 | """ 26 | condition: UsersEmailCondition 27 | ): [UsersEmail!]! 28 | } 29 | 30 | """ 31 | A condition to be used against \`Email\` object types. All fields are tested for equality and combined with a logical ‘and.’ 32 | """ 33 | input EmailCondition { 34 | """Checks for equality with the object’s \`id\` field.""" 35 | id: Int 36 | 37 | """Checks for equality with the object’s \`email\` field.""" 38 | email: String 39 | } 40 | 41 | """Methods to use when ordering \`Email\`.""" 42 | enum EmailsOrderBy { 43 | NATURAL 44 | ID_ASC 45 | ID_DESC 46 | EMAIL_ASC 47 | EMAIL_DESC 48 | PRIMARY_KEY_ASC 49 | PRIMARY_KEY_DESC 50 | } 51 | 52 | type Forum implements Node { 53 | """ 54 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 55 | """ 56 | nodeId: ID! 57 | id: Int! 58 | title: String! 59 | 60 | """Reads and enables pagination through a set of \`Post\`.""" 61 | postsByForumIdList( 62 | """Only read the first \`n\` values of the set.""" 63 | first: Int 64 | 65 | """Skip the first \`n\` values.""" 66 | offset: Int 67 | 68 | """The method to use when ordering \`Post\`.""" 69 | orderBy: [PostsOrderBy!] 70 | 71 | """ 72 | A condition to be used in determining which values should be returned by the collection. 73 | """ 74 | condition: PostCondition 75 | ): [Post!]! 76 | } 77 | 78 | """ 79 | A condition to be used against \`Forum\` object types. All fields are tested for equality and combined with a logical ‘and.’ 80 | """ 81 | input ForumCondition { 82 | """Checks for equality with the object’s \`id\` field.""" 83 | id: Int 84 | 85 | """Checks for equality with the object’s \`title\` field.""" 86 | title: String 87 | } 88 | 89 | """Methods to use when ordering \`Forum\`.""" 90 | enum ForumsOrderBy { 91 | NATURAL 92 | ID_ASC 93 | ID_DESC 94 | TITLE_ASC 95 | TITLE_DESC 96 | PRIMARY_KEY_ASC 97 | PRIMARY_KEY_DESC 98 | } 99 | 100 | """An object with a globally unique \`ID\`.""" 101 | interface Node { 102 | """ 103 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 104 | """ 105 | nodeId: ID! 106 | } 107 | 108 | type Post implements Node { 109 | """ 110 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 111 | """ 112 | nodeId: ID! 113 | id: Int! 114 | forumId: Int! 115 | body: String! 116 | 117 | """Reads a single \`Forum\` that is related to this \`Post\`.""" 118 | forumByForumId: Forum 119 | } 120 | 121 | """ 122 | A condition to be used against \`Post\` object types. All fields are tested for equality and combined with a logical ‘and.’ 123 | """ 124 | input PostCondition { 125 | """Checks for equality with the object’s \`id\` field.""" 126 | id: Int 127 | 128 | """Checks for equality with the object’s \`forumId\` field.""" 129 | forumId: Int 130 | 131 | """Checks for equality with the object’s \`body\` field.""" 132 | body: String 133 | } 134 | 135 | """Methods to use when ordering \`Post\`.""" 136 | enum PostsOrderBy { 137 | NATURAL 138 | ID_ASC 139 | ID_DESC 140 | FORUM_ID_ASC 141 | FORUM_ID_DESC 142 | BODY_ASC 143 | BODY_DESC 144 | PRIMARY_KEY_ASC 145 | PRIMARY_KEY_DESC 146 | } 147 | 148 | type Query { 149 | """Reads a set of \`Email\`.""" 150 | allEmailsList( 151 | """Only read the first \`n\` values of the set.""" 152 | first: Int 153 | 154 | """Skip the first \`n\` values.""" 155 | offset: Int 156 | 157 | """The method to use when ordering \`Email\`.""" 158 | orderBy: [EmailsOrderBy!] 159 | 160 | """ 161 | A condition to be used in determining which values should be returned by the collection. 162 | """ 163 | condition: EmailCondition 164 | ): [Email!] 165 | 166 | """Reads a set of \`User\`.""" 167 | allUsersList( 168 | """Only read the first \`n\` values of the set.""" 169 | first: Int 170 | 171 | """Skip the first \`n\` values.""" 172 | offset: Int 173 | 174 | """The method to use when ordering \`User\`.""" 175 | orderBy: [UsersOrderBy!] 176 | 177 | """ 178 | A condition to be used in determining which values should be returned by the collection. 179 | """ 180 | condition: UserCondition 181 | ): [User!] 182 | 183 | """Reads a set of \`UsersEmail\`.""" 184 | allUsersEmailsList( 185 | """Only read the first \`n\` values of the set.""" 186 | first: Int 187 | 188 | """Skip the first \`n\` values.""" 189 | offset: Int 190 | 191 | """The method to use when ordering \`UsersEmail\`.""" 192 | orderBy: [UsersEmailsOrderBy!] 193 | 194 | """ 195 | A condition to be used in determining which values should be returned by the collection. 196 | """ 197 | condition: UsersEmailCondition 198 | ): [UsersEmail!] 199 | emailById(id: Int!): Email 200 | userById(id: Int!): User 201 | usersEmailByUserIdAndEmailId(userId: Int!, emailId: Int!): UsersEmail 202 | 203 | """Reads a single \`Email\` using its globally unique \`ID\`.""" 204 | email( 205 | """The globally unique \`ID\` to be used in selecting a single \`Email\`.""" 206 | nodeId: ID! 207 | ): Email 208 | 209 | """Reads a single \`User\` using its globally unique \`ID\`.""" 210 | user( 211 | """The globally unique \`ID\` to be used in selecting a single \`User\`.""" 212 | nodeId: ID! 213 | ): User 214 | 215 | """Reads a single \`UsersEmail\` using its globally unique \`ID\`.""" 216 | usersEmail( 217 | """ 218 | The globally unique \`ID\` to be used in selecting a single \`UsersEmail\`. 219 | """ 220 | nodeId: ID! 221 | ): UsersEmail 222 | 223 | """Reads a set of \`Forum\`.""" 224 | allForumsList( 225 | """Only read the first \`n\` values of the set.""" 226 | first: Int 227 | 228 | """Skip the first \`n\` values.""" 229 | offset: Int 230 | 231 | """The method to use when ordering \`Forum\`.""" 232 | orderBy: [ForumsOrderBy!] 233 | 234 | """ 235 | A condition to be used in determining which values should be returned by the collection. 236 | """ 237 | condition: ForumCondition 238 | ): [Forum!] 239 | 240 | """Reads a set of \`Post\`.""" 241 | allPostsList( 242 | """Only read the first \`n\` values of the set.""" 243 | first: Int 244 | 245 | """Skip the first \`n\` values.""" 246 | offset: Int 247 | 248 | """The method to use when ordering \`Post\`.""" 249 | orderBy: [PostsOrderBy!] 250 | 251 | """ 252 | A condition to be used in determining which values should be returned by the collection. 253 | """ 254 | condition: PostCondition 255 | ): [Post!] 256 | forumById(id: Int!): Forum 257 | postById(id: Int!): Post 258 | 259 | """Reads a single \`Forum\` using its globally unique \`ID\`.""" 260 | forum( 261 | """The globally unique \`ID\` to be used in selecting a single \`Forum\`.""" 262 | nodeId: ID! 263 | ): Forum 264 | 265 | """Reads a single \`Post\` using its globally unique \`ID\`.""" 266 | post( 267 | """The globally unique \`ID\` to be used in selecting a single \`Post\`.""" 268 | nodeId: ID! 269 | ): Post 270 | empty: ID 271 | } 272 | 273 | type User implements Node { 274 | """ 275 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 276 | """ 277 | nodeId: ID! 278 | id: Int! 279 | firstName: String! 280 | lastName: String! 281 | 282 | """Reads and enables pagination through a set of \`UsersEmail\`.""" 283 | usersEmailsByUserIdList( 284 | """Only read the first \`n\` values of the set.""" 285 | first: Int 286 | 287 | """Skip the first \`n\` values.""" 288 | offset: Int 289 | 290 | """The method to use when ordering \`UsersEmail\`.""" 291 | orderBy: [UsersEmailsOrderBy!] 292 | 293 | """ 294 | A condition to be used in determining which values should be returned by the collection. 295 | """ 296 | condition: UsersEmailCondition 297 | ): [UsersEmail!]! 298 | fullName: String! 299 | } 300 | 301 | """ 302 | A condition to be used against \`User\` object types. All fields are tested for equality and combined with a logical ‘and.’ 303 | """ 304 | input UserCondition { 305 | """Checks for equality with the object’s \`id\` field.""" 306 | id: Int 307 | 308 | """Checks for equality with the object’s \`firstName\` field.""" 309 | firstName: String 310 | 311 | """Checks for equality with the object’s \`lastName\` field.""" 312 | lastName: String 313 | } 314 | 315 | type UsersEmail implements Node { 316 | """ 317 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 318 | """ 319 | nodeId: ID! 320 | userId: Int! 321 | emailId: Int! 322 | 323 | """Reads a single \`User\` that is related to this \`UsersEmail\`.""" 324 | userByUserId: User 325 | 326 | """Reads a single \`Email\` that is related to this \`UsersEmail\`.""" 327 | emailByEmailId: Email 328 | } 329 | 330 | """ 331 | A condition to be used against \`UsersEmail\` object types. All fields are tested 332 | for equality and combined with a logical ‘and.’ 333 | """ 334 | input UsersEmailCondition { 335 | """Checks for equality with the object’s \`userId\` field.""" 336 | userId: Int 337 | 338 | """Checks for equality with the object’s \`emailId\` field.""" 339 | emailId: Int 340 | } 341 | 342 | """Methods to use when ordering \`UsersEmail\`.""" 343 | enum UsersEmailsOrderBy { 344 | NATURAL 345 | USER_ID_ASC 346 | USER_ID_DESC 347 | EMAIL_ID_ASC 348 | EMAIL_ID_DESC 349 | PRIMARY_KEY_ASC 350 | PRIMARY_KEY_DESC 351 | } 352 | 353 | """Methods to use when ordering \`User\`.""" 354 | enum UsersOrderBy { 355 | NATURAL 356 | ID_ASC 357 | ID_DESC 358 | FIRST_NAME_ASC 359 | FIRST_NAME_DESC 360 | LAST_NAME_ASC 361 | LAST_NAME_DESC 362 | PRIMARY_KEY_ASC 363 | PRIMARY_KEY_DESC 364 | } 365 | 366 | `; 367 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/schema.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`schema and _service.sdl: _service.sdl 1`] = ` 4 | "type Email implements Node @key(fields: \\"nodeId\\") { 5 | \\"\\"\\" 6 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 7 | \\"\\"\\" 8 | nodeId: ID! 9 | id: Int! 10 | email: String! 11 | 12 | \\"\\"\\"Reads and enables pagination through a set of \`UsersEmail\`.\\"\\"\\" 13 | usersEmailsByEmailIdList( 14 | \\"\\"\\"Only read the first \`n\` values of the set.\\"\\"\\" 15 | first: Int 16 | 17 | \\"\\"\\"Skip the first \`n\` values.\\"\\"\\" 18 | offset: Int 19 | 20 | \\"\\"\\"The method to use when ordering \`UsersEmail\`.\\"\\"\\" 21 | orderBy: [UsersEmailsOrderBy!] 22 | 23 | \\"\\"\\" 24 | A condition to be used in determining which values should be returned by the collection. 25 | \\"\\"\\" 26 | condition: UsersEmailCondition 27 | ): [UsersEmail!]! 28 | } 29 | 30 | \\"\\"\\" 31 | A condition to be used against \`Email\` object types. All fields are tested for equality and combined with a logical ‘and.’ 32 | \\"\\"\\" 33 | input EmailCondition { 34 | \\"\\"\\"Checks for equality with the object’s \`id\` field.\\"\\"\\" 35 | id: Int 36 | 37 | \\"\\"\\"Checks for equality with the object’s \`email\` field.\\"\\"\\" 38 | email: String 39 | } 40 | 41 | \\"\\"\\"Methods to use when ordering \`Email\`.\\"\\"\\" 42 | enum EmailsOrderBy { 43 | NATURAL 44 | ID_ASC 45 | ID_DESC 46 | EMAIL_ASC 47 | EMAIL_DESC 48 | PRIMARY_KEY_ASC 49 | PRIMARY_KEY_DESC 50 | } 51 | 52 | \\"\\"\\"An object with a globally unique \`ID\`.\\"\\"\\" 53 | interface Node { 54 | \\"\\"\\" 55 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 56 | \\"\\"\\" 57 | nodeId: ID! 58 | } 59 | 60 | \\"\\"\\"The root query type which gives access points into the data universe.\\"\\"\\" 61 | type Query { 62 | \\"\\"\\"Reads a set of \`Email\`.\\"\\"\\" 63 | allEmailsList( 64 | \\"\\"\\"Only read the first \`n\` values of the set.\\"\\"\\" 65 | first: Int 66 | 67 | \\"\\"\\"Skip the first \`n\` values.\\"\\"\\" 68 | offset: Int 69 | 70 | \\"\\"\\"The method to use when ordering \`Email\`.\\"\\"\\" 71 | orderBy: [EmailsOrderBy!] 72 | 73 | \\"\\"\\" 74 | A condition to be used in determining which values should be returned by the collection. 75 | \\"\\"\\" 76 | condition: EmailCondition 77 | ): [Email!] 78 | 79 | \\"\\"\\"Reads a set of \`User\`.\\"\\"\\" 80 | allUsersList( 81 | \\"\\"\\"Only read the first \`n\` values of the set.\\"\\"\\" 82 | first: Int 83 | 84 | \\"\\"\\"Skip the first \`n\` values.\\"\\"\\" 85 | offset: Int 86 | 87 | \\"\\"\\"The method to use when ordering \`User\`.\\"\\"\\" 88 | orderBy: [UsersOrderBy!] 89 | 90 | \\"\\"\\" 91 | A condition to be used in determining which values should be returned by the collection. 92 | \\"\\"\\" 93 | condition: UserCondition 94 | ): [User!] 95 | 96 | \\"\\"\\"Reads a set of \`UsersEmail\`.\\"\\"\\" 97 | allUsersEmailsList( 98 | \\"\\"\\"Only read the first \`n\` values of the set.\\"\\"\\" 99 | first: Int 100 | 101 | \\"\\"\\"Skip the first \`n\` values.\\"\\"\\" 102 | offset: Int 103 | 104 | \\"\\"\\"The method to use when ordering \`UsersEmail\`.\\"\\"\\" 105 | orderBy: [UsersEmailsOrderBy!] 106 | 107 | \\"\\"\\" 108 | A condition to be used in determining which values should be returned by the collection. 109 | \\"\\"\\" 110 | condition: UsersEmailCondition 111 | ): [UsersEmail!] 112 | emailById(id: Int!): Email 113 | userById(id: Int!): User 114 | usersEmailByUserIdAndEmailId(userId: Int!, emailId: Int!): UsersEmail 115 | 116 | \\"\\"\\"Reads a single \`Email\` using its globally unique \`ID\`.\\"\\"\\" 117 | email( 118 | \\"\\"\\"The globally unique \`ID\` to be used in selecting a single \`Email\`.\\"\\"\\" 119 | nodeId: ID! 120 | ): Email 121 | 122 | \\"\\"\\"Reads a single \`User\` using its globally unique \`ID\`.\\"\\"\\" 123 | user( 124 | \\"\\"\\"The globally unique \`ID\` to be used in selecting a single \`User\`.\\"\\"\\" 125 | nodeId: ID! 126 | ): User 127 | 128 | \\"\\"\\"Reads a single \`UsersEmail\` using its globally unique \`ID\`.\\"\\"\\" 129 | usersEmail( 130 | \\"\\"\\" 131 | The globally unique \`ID\` to be used in selecting a single \`UsersEmail\`. 132 | \\"\\"\\" 133 | nodeId: ID! 134 | ): UsersEmail 135 | } 136 | 137 | type User implements Node @key(fields: \\"nodeId\\") { 138 | \\"\\"\\" 139 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 140 | \\"\\"\\" 141 | nodeId: ID! 142 | id: Int! 143 | firstName: String! 144 | lastName: String! 145 | 146 | \\"\\"\\"Reads and enables pagination through a set of \`UsersEmail\`.\\"\\"\\" 147 | usersEmailsByUserIdList( 148 | \\"\\"\\"Only read the first \`n\` values of the set.\\"\\"\\" 149 | first: Int 150 | 151 | \\"\\"\\"Skip the first \`n\` values.\\"\\"\\" 152 | offset: Int 153 | 154 | \\"\\"\\"The method to use when ordering \`UsersEmail\`.\\"\\"\\" 155 | orderBy: [UsersEmailsOrderBy!] 156 | 157 | \\"\\"\\" 158 | A condition to be used in determining which values should be returned by the collection. 159 | \\"\\"\\" 160 | condition: UsersEmailCondition 161 | ): [UsersEmail!]! 162 | } 163 | 164 | \\"\\"\\" 165 | A condition to be used against \`User\` object types. All fields are tested for equality and combined with a logical ‘and.’ 166 | \\"\\"\\" 167 | input UserCondition { 168 | \\"\\"\\"Checks for equality with the object’s \`id\` field.\\"\\"\\" 169 | id: Int 170 | 171 | \\"\\"\\"Checks for equality with the object’s \`firstName\` field.\\"\\"\\" 172 | firstName: String 173 | 174 | \\"\\"\\"Checks for equality with the object’s \`lastName\` field.\\"\\"\\" 175 | lastName: String 176 | } 177 | 178 | type UsersEmail implements Node @key(fields: \\"nodeId\\") { 179 | \\"\\"\\" 180 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 181 | \\"\\"\\" 182 | nodeId: ID! 183 | userId: Int! 184 | emailId: Int! 185 | 186 | \\"\\"\\"Reads a single \`User\` that is related to this \`UsersEmail\`.\\"\\"\\" 187 | userByUserId: User 188 | 189 | \\"\\"\\"Reads a single \`Email\` that is related to this \`UsersEmail\`.\\"\\"\\" 190 | emailByEmailId: Email 191 | } 192 | 193 | \\"\\"\\" 194 | A condition to be used against \`UsersEmail\` object types. All fields are tested 195 | for equality and combined with a logical ‘and.’ 196 | \\"\\"\\" 197 | input UsersEmailCondition { 198 | \\"\\"\\"Checks for equality with the object’s \`userId\` field.\\"\\"\\" 199 | userId: Int 200 | 201 | \\"\\"\\"Checks for equality with the object’s \`emailId\` field.\\"\\"\\" 202 | emailId: Int 203 | } 204 | 205 | \\"\\"\\"Methods to use when ordering \`UsersEmail\`.\\"\\"\\" 206 | enum UsersEmailsOrderBy { 207 | NATURAL 208 | USER_ID_ASC 209 | USER_ID_DESC 210 | EMAIL_ID_ASC 211 | EMAIL_ID_DESC 212 | PRIMARY_KEY_ASC 213 | PRIMARY_KEY_DESC 214 | } 215 | 216 | \\"\\"\\"Methods to use when ordering \`User\`.\\"\\"\\" 217 | enum UsersOrderBy { 218 | NATURAL 219 | ID_ASC 220 | ID_DESC 221 | FIRST_NAME_ASC 222 | FIRST_NAME_DESC 223 | LAST_NAME_ASC 224 | LAST_NAME_DESC 225 | PRIMARY_KEY_ASC 226 | PRIMARY_KEY_DESC 227 | } 228 | " 229 | `; 230 | 231 | exports[`schema and _service.sdl: external schema 1`] = ` 232 | directive @external on FIELD_DEFINITION 233 | 234 | directive @requires(fields: _FieldSet!) on FIELD_DEFINITION 235 | 236 | directive @provides(fields: _FieldSet!) on FIELD_DEFINITION 237 | 238 | directive @key(fields: _FieldSet!) on OBJECT | INTERFACE 239 | 240 | """The root query type which gives access points into the data universe.""" 241 | type Query { 242 | """Reads a set of \`Email\`.""" 243 | allEmailsList( 244 | """Only read the first \`n\` values of the set.""" 245 | first: Int 246 | 247 | """Skip the first \`n\` values.""" 248 | offset: Int 249 | 250 | """The method to use when ordering \`Email\`.""" 251 | orderBy: [EmailsOrderBy!] 252 | 253 | """ 254 | A condition to be used in determining which values should be returned by the collection. 255 | """ 256 | condition: EmailCondition 257 | ): [Email!] 258 | 259 | """Reads a set of \`User\`.""" 260 | allUsersList( 261 | """Only read the first \`n\` values of the set.""" 262 | first: Int 263 | 264 | """Skip the first \`n\` values.""" 265 | offset: Int 266 | 267 | """The method to use when ordering \`User\`.""" 268 | orderBy: [UsersOrderBy!] 269 | 270 | """ 271 | A condition to be used in determining which values should be returned by the collection. 272 | """ 273 | condition: UserCondition 274 | ): [User!] 275 | 276 | """Reads a set of \`UsersEmail\`.""" 277 | allUsersEmailsList( 278 | """Only read the first \`n\` values of the set.""" 279 | first: Int 280 | 281 | """Skip the first \`n\` values.""" 282 | offset: Int 283 | 284 | """The method to use when ordering \`UsersEmail\`.""" 285 | orderBy: [UsersEmailsOrderBy!] 286 | 287 | """ 288 | A condition to be used in determining which values should be returned by the collection. 289 | """ 290 | condition: UsersEmailCondition 291 | ): [UsersEmail!] 292 | emailById(id: Int!): Email 293 | userById(id: Int!): User 294 | usersEmailByUserIdAndEmailId(userId: Int!, emailId: Int!): UsersEmail 295 | 296 | """Reads a single \`Email\` using its globally unique \`ID\`.""" 297 | email( 298 | """The globally unique \`ID\` to be used in selecting a single \`Email\`.""" 299 | nodeId: ID! 300 | ): Email 301 | 302 | """Reads a single \`User\` using its globally unique \`ID\`.""" 303 | user( 304 | """The globally unique \`ID\` to be used in selecting a single \`User\`.""" 305 | nodeId: ID! 306 | ): User 307 | 308 | """Reads a single \`UsersEmail\` using its globally unique \`ID\`.""" 309 | usersEmail( 310 | """ 311 | The globally unique \`ID\` to be used in selecting a single \`UsersEmail\`. 312 | """ 313 | nodeId: ID! 314 | ): UsersEmail 315 | 316 | """ 317 | Fetches a list of entities using their representations; used for Apollo 318 | Federation. 319 | """ 320 | _entities(representations: [_Any!]!): [_Entity]! @deprecated(reason: "Only Apollo Federation should use this") 321 | 322 | """ 323 | Entrypoint for Apollo Federation to determine more information about 324 | this service. 325 | """ 326 | _service: _Service! @deprecated(reason: "Only Apollo Federation should use this") 327 | } 328 | 329 | type Email implements Node { 330 | """ 331 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 332 | """ 333 | nodeId: ID! 334 | id: Int! 335 | email: String! 336 | 337 | """Reads and enables pagination through a set of \`UsersEmail\`.""" 338 | usersEmailsByEmailIdList( 339 | """Only read the first \`n\` values of the set.""" 340 | first: Int 341 | 342 | """Skip the first \`n\` values.""" 343 | offset: Int 344 | 345 | """The method to use when ordering \`UsersEmail\`.""" 346 | orderBy: [UsersEmailsOrderBy!] 347 | 348 | """ 349 | A condition to be used in determining which values should be returned by the collection. 350 | """ 351 | condition: UsersEmailCondition 352 | ): [UsersEmail!]! 353 | } 354 | 355 | """An object with a globally unique \`ID\`.""" 356 | interface Node { 357 | """ 358 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 359 | """ 360 | nodeId: ID! 361 | } 362 | 363 | type UsersEmail implements Node { 364 | """ 365 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 366 | """ 367 | nodeId: ID! 368 | userId: Int! 369 | emailId: Int! 370 | 371 | """Reads a single \`User\` that is related to this \`UsersEmail\`.""" 372 | userByUserId: User 373 | 374 | """Reads a single \`Email\` that is related to this \`UsersEmail\`.""" 375 | emailByEmailId: Email 376 | } 377 | 378 | type User implements Node { 379 | """ 380 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 381 | """ 382 | nodeId: ID! 383 | id: Int! 384 | firstName: String! 385 | lastName: String! 386 | 387 | """Reads and enables pagination through a set of \`UsersEmail\`.""" 388 | usersEmailsByUserIdList( 389 | """Only read the first \`n\` values of the set.""" 390 | first: Int 391 | 392 | """Skip the first \`n\` values.""" 393 | offset: Int 394 | 395 | """The method to use when ordering \`UsersEmail\`.""" 396 | orderBy: [UsersEmailsOrderBy!] 397 | 398 | """ 399 | A condition to be used in determining which values should be returned by the collection. 400 | """ 401 | condition: UsersEmailCondition 402 | ): [UsersEmail!]! 403 | } 404 | 405 | """Methods to use when ordering \`UsersEmail\`.""" 406 | enum UsersEmailsOrderBy { 407 | NATURAL 408 | USER_ID_ASC 409 | USER_ID_DESC 410 | EMAIL_ID_ASC 411 | EMAIL_ID_DESC 412 | PRIMARY_KEY_ASC 413 | PRIMARY_KEY_DESC 414 | } 415 | 416 | """ 417 | A condition to be used against \`UsersEmail\` object types. All fields are tested 418 | for equality and combined with a logical ‘and.’ 419 | """ 420 | input UsersEmailCondition { 421 | """Checks for equality with the object’s \`userId\` field.""" 422 | userId: Int 423 | 424 | """Checks for equality with the object’s \`emailId\` field.""" 425 | emailId: Int 426 | } 427 | 428 | """Methods to use when ordering \`Email\`.""" 429 | enum EmailsOrderBy { 430 | NATURAL 431 | ID_ASC 432 | ID_DESC 433 | EMAIL_ASC 434 | EMAIL_DESC 435 | PRIMARY_KEY_ASC 436 | PRIMARY_KEY_DESC 437 | } 438 | 439 | """ 440 | A condition to be used against \`Email\` object types. All fields are tested for equality and combined with a logical ‘and.’ 441 | """ 442 | input EmailCondition { 443 | """Checks for equality with the object’s \`id\` field.""" 444 | id: Int 445 | 446 | """Checks for equality with the object’s \`email\` field.""" 447 | email: String 448 | } 449 | 450 | """Methods to use when ordering \`User\`.""" 451 | enum UsersOrderBy { 452 | NATURAL 453 | ID_ASC 454 | ID_DESC 455 | FIRST_NAME_ASC 456 | FIRST_NAME_DESC 457 | LAST_NAME_ASC 458 | LAST_NAME_DESC 459 | PRIMARY_KEY_ASC 460 | PRIMARY_KEY_DESC 461 | } 462 | 463 | """ 464 | A condition to be used against \`User\` object types. All fields are tested for equality and combined with a logical ‘and.’ 465 | """ 466 | input UserCondition { 467 | """Checks for equality with the object’s \`id\` field.""" 468 | id: Int 469 | 470 | """Checks for equality with the object’s \`firstName\` field.""" 471 | firstName: String 472 | 473 | """Checks for equality with the object’s \`lastName\` field.""" 474 | lastName: String 475 | } 476 | 477 | """A union of all federated types (those that use the @key directive).""" 478 | union _Entity = Email | UsersEmail | User 479 | 480 | scalar _Any 481 | 482 | """Describes our federated service.""" 483 | type _Service { 484 | """ 485 | The GraphQL Schema Language definiton of our endpoint including the 486 | Apollo Federation directives (but not their definitions or the special 487 | Apollo Federation fields). 488 | """ 489 | sdl: String @deprecated(reason: "Only Apollo Federation should use this") 490 | } 491 | 492 | """ 493 | Used to represent a set of fields. Grammatically, a field set is a 494 | selection set minus the braces. 495 | """ 496 | scalar _FieldSet 497 | 498 | `; 499 | -------------------------------------------------------------------------------- /__tests__/integration.test.ts: -------------------------------------------------------------------------------- 1 | import federationPlugin from "../src"; 2 | import * as http from "http"; 3 | import { postgraphile } from "postgraphile"; 4 | import { ApolloGateway } from "@apollo/gateway"; 5 | import { startFederatedServiceExtendingUser } from "./__fixtures__/federatedServiceExtendingUser"; 6 | import { ApolloServer, ServerInfo } from "apollo-server"; 7 | import * as pg from "pg"; 8 | import axios from "axios"; 9 | import { GraphQLSchema } from "graphql"; 10 | 11 | let pgPool: pg.Pool | null; 12 | 13 | beforeAll(() => { 14 | pgPool = new pg.Pool({ 15 | connectionString: process.env.TEST_DATABASE_URL, 16 | }); 17 | }); 18 | 19 | afterAll(() => { 20 | if (pgPool) { 21 | pgPool.end(); 22 | pgPool = null; 23 | } 24 | }); 25 | 26 | function startPostgraphile( 27 | schema = "graphile_federation" 28 | ): Promise { 29 | return new Promise((resolve) => { 30 | if (!pgPool) { 31 | throw new Error("pool not ready!"); 32 | } 33 | const httpServer = http.createServer( 34 | postgraphile(pgPool, schema, { 35 | disableDefaultMutations: true, 36 | appendPlugins: [federationPlugin], 37 | simpleCollections: "only", 38 | retryOnInitFail: true, 39 | }) 40 | ); 41 | 42 | httpServer.once("listening", () => resolve(httpServer)); 43 | httpServer.listen({ port: 0, host: "127.0.0.1" }); 44 | }); 45 | } 46 | 47 | function toUrl( 48 | obj: string | { address: string; port: number | string; family: string } 49 | ) { 50 | return typeof obj === "string" 51 | ? obj 52 | : obj.family === "IPv6" 53 | ? `http://[${obj.address}]:${obj.port}` 54 | : `http://${obj.address}:${obj.port}`; 55 | } 56 | 57 | async function withFederatedExternalServices( 58 | startExternalServices: { 59 | [serviceName: string]: () => ApolloServer | Promise; 60 | }, 61 | cb: (_: { serverInfo: ServerInfo; schema: GraphQLSchema }) => Promise 62 | ) { 63 | const postgraphileServer = await startPostgraphile(); 64 | const postgraphileServer2 = await startPostgraphile("graphile_federation2"); 65 | const externalServices = await Promise.all( 66 | Object.entries(startExternalServices).map( 67 | async ([name, serviceBuilder]) => { 68 | const service = await serviceBuilder(); 69 | return { 70 | name, 71 | service, 72 | url: toUrl(await service.listen({ port: 0, host: "127.0.0.1" })), 73 | }; 74 | } 75 | ) 76 | ); 77 | let server: ApolloServer | undefined; 78 | 79 | try { 80 | const serviceList = [ 81 | { 82 | name: "postgraphile", 83 | url: toUrl(postgraphileServer.address()!) + "/graphql", 84 | }, 85 | { 86 | name: "postgraphile2", 87 | url: toUrl(postgraphileServer2.address()!) + "/graphql", 88 | }, 89 | ...externalServices, 90 | ]; 91 | 92 | const { schema, executor } = await new ApolloGateway({ 93 | serviceList, 94 | }).load(); 95 | 96 | server = new ApolloServer({ 97 | schema, 98 | executor, 99 | }); 100 | 101 | const serverInfo = await server.listen({ port: 0, host: "127.0.0.1" }); 102 | 103 | await cb({ serverInfo, schema }); 104 | } finally { 105 | await postgraphileServer.close(); 106 | await postgraphileServer2.close(); 107 | for (const external of externalServices) { 108 | await external.service.stop(); 109 | } 110 | if (server) { 111 | await server.stop(); 112 | } 113 | } 114 | } 115 | 116 | test("federated service", async () => { 117 | await withFederatedExternalServices( 118 | { 119 | serviceExteningUser: startFederatedServiceExtendingUser, 120 | }, 121 | async ({ serverInfo, schema }) => { 122 | expect(schema).toMatchSnapshot("federated schema"); 123 | 124 | const result = await axios.post(serverInfo.url, { 125 | query: `{ allUsersList(first: 1) { firstName, lastName, fullName} allForumsList { id title postsByForumIdList { id body } } }`, 126 | }); 127 | 128 | expect(result.data).toMatchObject({ 129 | data: { 130 | allUsersList: [ 131 | { 132 | firstName: "alicia", 133 | fullName: "alicia keys", 134 | lastName: "keys", 135 | }, 136 | ], 137 | allForumsList: [ 138 | { 139 | id: 1, 140 | title: "Cats", 141 | postsByForumIdList: [{ id: 1, body: "They are sneaky" }], 142 | }, 143 | { 144 | id: 2, 145 | title: "Dogs", 146 | postsByForumIdList: [{ id: 2, body: "They are loyal" }], 147 | }, 148 | { 149 | id: 3, 150 | title: "Postgres", 151 | postsByForumIdList: [{ id: 3, body: "It's awesome" }], 152 | }, 153 | ], 154 | }, 155 | }); 156 | } 157 | ); 158 | }); 159 | -------------------------------------------------------------------------------- /__tests__/schema.test.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | import { graphql, ObjectTypeDefinitionNode } from "graphql"; 3 | import { 4 | createPostGraphileSchema, 5 | PostGraphileCoreOptions, 6 | } from "postgraphile-core"; 7 | import { gql } from "graphile-utils"; 8 | import federationPlugin from "../src"; 9 | import { GraphQLSchema } from "graphql"; 10 | 11 | let pgPool: pg.Pool | null; 12 | 13 | beforeAll(() => { 14 | pgPool = new pg.Pool({ 15 | connectionString: process.env.TEST_DATABASE_URL, 16 | }); 17 | }); 18 | 19 | afterAll(() => { 20 | if (pgPool) { 21 | pgPool.end(); 22 | pgPool = null; 23 | } 24 | }); 25 | 26 | function buildTestSchema(override?: PostGraphileCoreOptions) { 27 | return createPostGraphileSchema(pgPool!, ["graphile_federation"], { 28 | disableDefaultMutations: true, 29 | appendPlugins: [federationPlugin], 30 | simpleCollections: "only", 31 | ...override, 32 | }); 33 | } 34 | 35 | async function queryWithTestSchema( 36 | schema: GraphQLSchema | Promise, 37 | query: string 38 | ) { 39 | const pgClient = await pgPool!.connect(); 40 | try { 41 | return graphql(await schema, query, null, { pgClient }, {}); 42 | } finally { 43 | pgClient.release(); 44 | } 45 | } 46 | 47 | test("schema and _service.sdl", async () => { 48 | const schema = await buildTestSchema(); 49 | expect(schema).toMatchSnapshot("external schema"); 50 | 51 | const { data, errors } = await graphql( 52 | schema, 53 | ` 54 | query { 55 | _service { 56 | sdl 57 | } 58 | } 59 | `, 60 | null, 61 | {}, 62 | {} 63 | ); 64 | 65 | expect(errors).toBeUndefined(); 66 | expect(data!._service.sdl).toMatchSnapshot("_service.sdl"); 67 | 68 | const parsed = gql([data!._service.sdl] as any); 69 | 70 | const emailDefinition = parsed.definitions.find( 71 | def => def.kind === "ObjectTypeDefinition" && def.name.value === "Email" 72 | ) as ObjectTypeDefinitionNode; 73 | 74 | expect(emailDefinition.directives).toEqual( 75 | expect.arrayContaining([ 76 | expect.objectContaining({ 77 | name: expect.objectContaining({ value: "key" }), 78 | arguments: [ 79 | expect.objectContaining({ 80 | name: expect.objectContaining({ value: "fields" }), 81 | value: expect.objectContaining({ value: "nodeId" }), 82 | }), 83 | ], 84 | }), 85 | ]) 86 | ); 87 | }); 88 | 89 | test("querying _entities by nodeId", async () => { 90 | const { data, errors } = await queryWithTestSchema( 91 | buildTestSchema(), 92 | ` 93 | query { 94 | _entities( 95 | representations: [ 96 | { __typename: "User", nodeId: "WyJ1c2VycyIsMV0=" } 97 | ] 98 | ) { 99 | ... on User { 100 | __typename 101 | nodeId 102 | id 103 | firstName 104 | } 105 | } 106 | } 107 | ` 108 | ); 109 | expect(errors).toBeUndefined(); 110 | expect(data && data._entities).toEqual([ 111 | { 112 | __typename: "User", 113 | id: 1, 114 | firstName: "alicia", 115 | nodeId: expect.any(String), 116 | }, 117 | ]); 118 | }); 119 | -------------------------------------------------------------------------------- /__tests__/setupSchema.sql: -------------------------------------------------------------------------------- 1 | 2 | -- WARNING: this database is shared with postgraphile-core, don't run the tests in parallel! 3 | DROP SCHEMA IF EXISTS graphile_federation, graphile_federation2 CASCADE; 4 | 5 | CREATE SCHEMA graphile_federation; 6 | 7 | CREATE TABLE graphile_federation.users ( 8 | id SERIAL PRIMARY KEY, 9 | first_name TEXT NOT NULL, 10 | last_name TEXT NOT NULL 11 | ); 12 | 13 | CREATE TABLE graphile_federation.emails ( 14 | id SERIAL PRIMARY KEY, 15 | email TEXT NOT NULL 16 | ); 17 | 18 | CREATE TABLE graphile_federation.users_emails ( 19 | user_id INT NOT NULL REFERENCES graphile_federation.users(id), 20 | email_id INT NOT NULL REFERENCES graphile_federation.emails(id), 21 | PRIMARY KEY ( 22 | user_id, 23 | email_id 24 | ) 25 | ) ; 26 | 27 | INSERT 28 | INTO 29 | graphile_federation.users ( 30 | id, 31 | first_name, 32 | last_name 33 | ) 34 | VALUES ( 35 | 1, 36 | 'alicia', 37 | 'keys' 38 | ), 39 | ( 40 | 2, 41 | 'bob', 42 | 'marley' 43 | ), 44 | ( 45 | 3, 46 | 'charles', 47 | 'bradley' 48 | ); 49 | 50 | INSERT 51 | INTO 52 | graphile_federation.emails ( 53 | id, 54 | email 55 | ) 56 | VALUES ( 57 | 1, 58 | 'piano@example.com' 59 | ), 60 | ( 61 | 2, 62 | 'alicia@example.com' 63 | ); 64 | 65 | INSERT 66 | INTO 67 | graphile_federation.users_emails ( 68 | user_id, 69 | email_id 70 | ) 71 | VALUES ( 72 | 1, 73 | 1 74 | ), 75 | ( 76 | 1, 77 | 2 78 | ); 79 | 80 | CREATE SCHEMA graphile_federation2; 81 | CREATE TABLE graphile_federation2.forums ( 82 | id SERIAL PRIMARY KEY, 83 | title TEXT NOT NULL 84 | ); 85 | CREATE TABLE graphile_federation2.posts ( 86 | id SERIAL PRIMARY KEY, 87 | forum_id int not null references graphile_federation2.forums, 88 | body TEXT NOT NULL 89 | ); 90 | 91 | insert into graphile_federation2.forums (title) values 92 | ('Cats'), 93 | ('Dogs'), 94 | ('Postgres'); 95 | 96 | insert into graphile_federation2.posts (forum_id, body) values 97 | (1, 'They are sneaky'), 98 | (2, 'They are loyal'), 99 | (3, 'It''s awesome'); 100 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".." 5 | }, 6 | "include": ["**/*.ts"], 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | snapshotSerializers: ["jest-serializer-graphql-schema"], 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest", 6 | }, 7 | testMatch: ["**/__tests__/**/*.test.[jt]s?(x)"], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@graphile/federation", 3 | "version": "0.1.0", 4 | "description": "Apollo Federation support for Graphile Engine", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "lint": "eslint 'src/**/*.ts'", 9 | "watch": "tsc --watch", 10 | "test": "./scripts/test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/graphile/federation.git" 15 | }, 16 | "keywords": [ 17 | "apollo", 18 | "federation", 19 | "graphile", 20 | "postgraphile", 21 | "engine", 22 | "postgres", 23 | "pg", 24 | "postgresql" 25 | ], 26 | "author": "Benjie Gillam ", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/graphile/federation/issues" 30 | }, 31 | "homepage": "https://github.com/graphile/federation#readme", 32 | "dependencies": { 33 | "@apollo/federation": "^0.25.0", 34 | "@graphql-tools/wrap": "^8.0.0", 35 | "@types/graphql": "^14.5.0", 36 | "graphile-utils": "^4.4.5" 37 | }, 38 | "devDependencies": { 39 | "@apollo/gateway": "^0.28.1", 40 | "@types/jest": "^26.0.23", 41 | "@typescript-eslint/eslint-plugin": "^4.23.0", 42 | "@typescript-eslint/parser": "^4.23.0", 43 | "apollo-server": "^2.24.0", 44 | "axios": "^0.28.0", 45 | "eslint": "^7.26.0", 46 | "eslint-config-prettier": "^8.3.0", 47 | "eslint-plugin-jest": "^24.3.6", 48 | "eslint-plugin-prettier": "^3.4.0", 49 | "eslint_d": "^10.1.1", 50 | "jest": "^26.6.3", 51 | "jest-serializer-graphql-schema": "^4.10.0", 52 | "pg": ">=7.12.1 <9", 53 | "postgraphile": "^4.4.4", 54 | "postgraphile-core": "^4.4.5", 55 | "prettier": "^2.3.0", 56 | "ts-jest": "^26.5.6", 57 | "typescript": "^4.3.5" 58 | }, 59 | "peerDependencies": { 60 | "graphile-build": "^4.4.2" 61 | }, 62 | "files": [ 63 | "build" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | if [ -x ".env" ]; then 5 | set -a 6 | . ./.env 7 | set +a 8 | fi; 9 | 10 | if [ "$TEST_DATABASE_URL" == "" ]; then 11 | echo "ERROR: No test database configured; aborting" 12 | echo 13 | echo "To resolve this, ensure environmental variable TEST_DATABASE_URL is set" 14 | exit 1; 15 | fi; 16 | 17 | # Import latest schema (throw on error) 18 | psql -Xqv ON_ERROR_STOP=1 -f __tests__/setupSchema.sql "$TEST_DATABASE_URL" 19 | clear 20 | echo "Database reset successfully ✅" 21 | 22 | # Now run the tests 23 | jest -i $@ 24 | 25 | -------------------------------------------------------------------------------- /src/AST.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * These helpers help us to construct AST nodes required for Apollo 3 | * Federation's printSchema to work. 4 | */ 5 | export function Name(value: string) { 6 | return { 7 | kind: "Name", 8 | value, 9 | }; 10 | } 11 | 12 | export function StringValue(value: string, block = false) { 13 | return { 14 | kind: "StringValue", 15 | value, 16 | block, 17 | }; 18 | } 19 | 20 | export function ObjectTypeDefinition(spec: { 21 | name: string; 22 | description?: string | null; 23 | }) { 24 | return { 25 | kind: "ObjectTypeDefinition", 26 | name: Name(spec.name), 27 | description: spec.description 28 | ? StringValue(spec.description, true) 29 | : undefined, 30 | directives: [], 31 | }; 32 | } 33 | 34 | export function Directive(name: string, args: { [argName: string]: any } = {}) { 35 | return { 36 | kind: "Directive", 37 | name: Name(name), 38 | arguments: Object.entries(args).map(([argName, value]) => ({ 39 | kind: "Argument", 40 | name: Name(argName), 41 | value, 42 | })), 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | makeExtendSchemaPlugin, 3 | makePluginByCombiningPlugins, 4 | gql, 5 | } from "graphile-utils"; 6 | import { Plugin } from "graphile-build"; 7 | import printFederatedSchema from "./printFederatedSchema"; 8 | import { ObjectTypeDefinition, Directive, StringValue } from "./AST"; 9 | 10 | /** 11 | * This plugin installs the schema outlined in the Apollo Federation spec, and 12 | * the resolvers and types required. Comments have been added to make things 13 | * clearer for consumers, and the Apollo fields have been deprecated so that 14 | * users unconcerned with federation don't get confused. 15 | * 16 | * https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#federation-schema-specification 17 | */ 18 | const SchemaExtensionPlugin = makeExtendSchemaPlugin(build => { 19 | const { 20 | graphql: { GraphQLScalarType, getNullableType }, 21 | resolveNode, 22 | $$isQuery, 23 | $$nodeType, 24 | getTypeByName, 25 | inflection, 26 | nodeIdFieldName, 27 | } = build; 28 | // Cache 29 | let Query: any; 30 | return { 31 | typeDefs: gql` 32 | """ 33 | Used to represent a federated entity via its keys. 34 | """ 35 | scalar _Any 36 | 37 | """ 38 | Used to represent a set of fields. Grammatically, a field set is a 39 | selection set minus the braces. 40 | """ 41 | scalar _FieldSet 42 | 43 | """ 44 | A union of all federated types (those that use the @key directive). 45 | """ 46 | union _Entity 47 | 48 | """ 49 | Describes our federated service. 50 | """ 51 | type _Service { 52 | """ 53 | The GraphQL Schema Language definiton of our endpoint including the 54 | Apollo Federation directives (but not their definitions or the special 55 | Apollo Federation fields). 56 | """ 57 | sdl: String 58 | @deprecated(reason: "Only Apollo Federation should use this") 59 | } 60 | 61 | extend type Query { 62 | """ 63 | Fetches a list of entities using their representations; used for Apollo 64 | Federation. 65 | """ 66 | _entities(representations: [_Any!]!): [_Entity]! 67 | @deprecated(reason: "Only Apollo Federation should use this") 68 | """ 69 | Entrypoint for Apollo Federation to determine more information about 70 | this service. 71 | """ 72 | _service: _Service! 73 | @deprecated(reason: "Only Apollo Federation should use this") 74 | } 75 | 76 | directive @external on FIELD_DEFINITION 77 | directive @requires(fields: _FieldSet!) on FIELD_DEFINITION 78 | directive @provides(fields: _FieldSet!) on FIELD_DEFINITION 79 | directive @key(fields: _FieldSet!) on OBJECT | INTERFACE 80 | `, 81 | resolvers: { 82 | Query: { 83 | _entities(data, { representations }, context, resolveInfo) { 84 | const { 85 | graphile: { fieldContext }, 86 | } = resolveInfo; 87 | return representations.map((representation: any) => { 88 | if (!representation || typeof representation !== "object") { 89 | throw new Error("Invalid representation"); 90 | } 91 | const { __typename, [nodeIdFieldName]: nodeId } = representation; 92 | if (!__typename || typeof nodeId !== "string") { 93 | throw new Error("Failed to interpret representation"); 94 | } 95 | return resolveNode( 96 | nodeId, 97 | build, 98 | fieldContext, 99 | data, 100 | context, 101 | resolveInfo 102 | ); 103 | }); 104 | }, 105 | 106 | _service(_, _args, _context, { schema }) { 107 | return schema; 108 | }, 109 | }, 110 | 111 | _Service: { 112 | sdl(schema) { 113 | return printFederatedSchema(schema); 114 | }, 115 | }, 116 | 117 | _Entity: { 118 | __resolveType(value) { 119 | // This uses the same resolution as the Node interface, which can be found in graphile-build's NodePlugin 120 | if (value === $$isQuery) { 121 | if (!Query) Query = getTypeByName(inflection.builtin("Query")); 122 | return Query; 123 | } else if (value[$$nodeType]) { 124 | return getNullableType(value[$$nodeType]); 125 | } 126 | }, 127 | }, 128 | 129 | _Any: new GraphQLScalarType({ 130 | name: "_Any", 131 | serialize(value: any) { 132 | return value; 133 | }, 134 | }) as any /* work around bug in the TypeScript definitions for makeExtendSchemaPlugin */, 135 | }, 136 | }; 137 | }); 138 | 139 | /* 140 | * This plugin adds the `@key(fields: "nodeId")` directive to the types that 141 | * implement the Node interface, and adds these types to the _Entity union 142 | * defined above. 143 | */ 144 | const AddKeyPlugin: Plugin = builder => { 145 | builder.hook("build", build => { 146 | build.federationEntityTypes = []; 147 | return build; 148 | }); 149 | 150 | // Find out what types implement the Node interface 151 | builder.hook("GraphQLObjectType:interfaces", (interfaces, build, context) => { 152 | const { getTypeByName, inflection, nodeIdFieldName } = build; 153 | const { 154 | GraphQLObjectType: spec, 155 | Self, 156 | scope: { isRootQuery }, 157 | } = context; 158 | const NodeInterface = getTypeByName(inflection.builtin("Node")); 159 | 160 | /* 161 | * We only want to add federation to types that implement the Node 162 | * interface, and aren't the Query root type. 163 | */ 164 | if (isRootQuery || !NodeInterface || !interfaces.includes(NodeInterface)) { 165 | return interfaces; 166 | } 167 | 168 | // Add this to the list of types to be in the _Entity union 169 | build.federationEntityTypes.push(Self); 170 | 171 | /* 172 | * We're going to add the `@key(fields: "nodeId")` directive to this type. 173 | * First, we need to generate an `astNode` as if the type was generateted 174 | * from a GraphQL SDL initially; then we assign this astNode to to the type 175 | * (via type mutation, ick) so that Apollo Federation's `printSchema` can 176 | * output it. 177 | */ 178 | const astNode = { 179 | ...ObjectTypeDefinition(spec), 180 | ...Self.astNode, 181 | }; 182 | astNode.directives.push( 183 | Directive("key", { fields: StringValue(nodeIdFieldName) }) 184 | ); 185 | Self.astNode = astNode; 186 | 187 | // We're not changing the interfaces, so return them unmodified. 188 | return interfaces; 189 | }); 190 | 191 | // Add our collected types to the _Entity union 192 | builder.hook("GraphQLUnionType:types", (types, build, context) => { 193 | const { Self } = context; 194 | // If it's not the _Entity union, don't change it. 195 | if (Self.name !== "_Entity") { 196 | return types; 197 | } 198 | const { federationEntityTypes } = build; 199 | 200 | // Add our types to the entity types 201 | return [...types, ...federationEntityTypes]; 202 | }); 203 | }; 204 | 205 | /* 206 | * This plugin remove query/node/nodeId fields and Node interface from Query type to 207 | * fix `GraphQLSchemaValidationError: There can be only one type named "query/node/nodeId"` error. 208 | * This helps Apollo Gateway to consume two or more PostGraphile services. 209 | */ 210 | 211 | const RemoveQueryLegacyFeaturesPlugin: Plugin = builder => { 212 | builder.hook('GraphQLObjectType:fields', (fields, _, context) => { 213 | const { 214 | scope: { isRootQuery }, 215 | } = context; 216 | 217 | // Deleting the query, node, nodeId fields from the Query type that are used by 218 | // the old relay specification which are not needed for modern GraphQL clients 219 | if (isRootQuery) { 220 | delete fields.query; 221 | delete fields.node; 222 | delete fields.nodeId; 223 | } 224 | 225 | return fields; 226 | }); 227 | 228 | builder.hook('GraphQLObjectType:interfaces', (interfaces, _, context) => { 229 | if (!context.scope.isRootQuery) { 230 | return interfaces; 231 | } 232 | // Delete all interfaces (i.e. the Node interface) from Query. 233 | return []; 234 | }); 235 | }; 236 | 237 | // Our federation implementation combines these plugins: 238 | export default makePluginByCombiningPlugins( 239 | SchemaExtensionPlugin, 240 | AddKeyPlugin, 241 | RemoveQueryLegacyFeaturesPlugin, 242 | ); 243 | -------------------------------------------------------------------------------- /src/printFederatedSchema.ts: -------------------------------------------------------------------------------- 1 | import { printSchema } from "@apollo/federation"; 2 | import { 3 | wrapSchema, 4 | FilterTypes, 5 | TransformRootFields, 6 | } from "@graphql-tools/wrap"; 7 | import { GraphQLSchema } from "graphql"; 8 | 9 | /* 10 | * These are the fields and types that will be stripped from the printed 11 | * schema. 12 | */ 13 | const FEDERATION_QUERY_FIELDS = ["_entities", "_service"]; 14 | const FEDERATION_TYPE_NAMES = ["_Any", "_FieldSet", "_Service"]; 15 | 16 | // For memoization: 17 | let lastSchema: any; 18 | let lastPrint: string; 19 | 20 | /** 21 | * When we print the federated schema we need to transform it to remove the 22 | * Apollo Federation fields (whilst keeping the directives). We need to use the 23 | * special `printSchema` function from the `@apollo/federation` package because 24 | * GraphQL's `printSchema` does not include directives. 25 | * 26 | * We've added simple memoization for performance reasons; better memoization 27 | * may be needed if you're dealing with multiple concurrent GraphQL schemas. 28 | */ 29 | export default function printFederatedSchema(schema: GraphQLSchema) { 30 | // If the schema is new or has changed, recalculate. 31 | if (schema !== lastSchema) { 32 | lastSchema = schema; 33 | /** 34 | * The Apollo federation spec states: 35 | * 36 | * > The federation schema modifications (i.e. new types and directives) 37 | * > should not be included in this SDL. 38 | * 39 | * But we need these fields in the schema for resolution to work, so we're 40 | * removing them from the schema that gets printed only. 41 | */ 42 | const schemaSansFederationFields = wrapSchema({ 43 | schema, 44 | transforms: [ 45 | // Remove the federation fields: 46 | new TransformRootFields((operation, fieldName, _field) => { 47 | if ( 48 | operation === "Query" && 49 | FEDERATION_QUERY_FIELDS.includes(fieldName) 50 | ) { 51 | // Federation query fields: remove (null). 52 | return null; 53 | } 54 | // No change (undefined). 55 | return undefined; 56 | }), 57 | // Remove the federation types: 58 | new FilterTypes((type) => !FEDERATION_TYPE_NAMES.includes(type.name)), 59 | ], 60 | }); 61 | 62 | // Print the schema, including the federation directives. 63 | lastPrint = printSchema(schemaSansFederationFields); 64 | } 65 | return lastPrint; 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveWatchOutput": true, 4 | 5 | "rootDir": "src", 6 | "outDir": "build", 7 | "declaration": true, 8 | "declarationDir": "./build", 9 | "allowJs": false, 10 | "sourceMap": true, 11 | "inlineSourceMap": false, 12 | 13 | "target": "es2021", 14 | "module": "commonjs", 15 | "lib": ["es2021"], 16 | "esModuleInterop": true, 17 | 18 | "pretty": true, 19 | "removeComments": false, 20 | "preserveConstEnums": true, 21 | 22 | "moduleResolution": "node", 23 | "resolveJsonModule": true, 24 | "importHelpers": true, 25 | 26 | "strict": true, 27 | "suppressImplicitAnyIndexErrors": true, 28 | 29 | "noFallthroughCasesInSwitch": false, 30 | "noUnusedParameters": false, 31 | "noUnusedLocals": false 32 | }, 33 | "exclude": [ 34 | "node_modules", 35 | "build", 36 | "build-turbo", 37 | "__tests__", 38 | "**/__tests__" 39 | ] 40 | } 41 | --------------------------------------------------------------------------------