├── .changeset └── config.json ├── .circleci └── config.yml ├── .github └── workflows │ ├── linux-ci.yml │ ├── macos-ci.yml │ ├── release-pr.yml │ └── windows-ci.yml ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE.md ├── README.md ├── generate-errors.js ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json5 ├── src ├── __tests__ │ ├── de.test.ts │ ├── error.test.ts │ ├── link-url.test.ts │ ├── linker.test.ts │ ├── renaming.test.ts │ ├── schema.test.ts │ ├── scope-map.test.ts │ ├── scope.test.ts │ ├── tsconfig.json │ └── version.test.ts ├── atlas.ts ├── de.ts ├── directives.ts ├── each.ts ├── error.ts ├── errors.ts ├── gql.ts ├── gref.ts ├── import.ts ├── index.ts ├── is.ts ├── link-url.ts ├── linker.ts ├── names.ts ├── schema.ts ├── scope-map.ts ├── scope.ts ├── snapshot-serializers │ ├── ast.ts │ ├── gref.ts │ ├── iterable.ts │ ├── raw.ts │ └── redirect.ts └── version.ts ├── tsconfig.json └── tsconfig.test.json /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.3/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "apollographql/core-schema-js" } 6 | ], 7 | "commit": false, 8 | "access": "public", 9 | "baseBranch": "main" 10 | } 11 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | secops: apollo/circleci-secops-orb@2.0.7 5 | 6 | workflows: 7 | security-scans: 8 | jobs: 9 | - secops/gitleaks: 10 | context: 11 | - platform-docker-ro 12 | - github-orb 13 | - secops-oidc 14 | git-base-revision: <<#pipeline.git.base_revision>><><> 15 | git-revision: << pipeline.git.revision >> 16 | - secops/semgrep: 17 | context: 18 | - secops-oidc 19 | - github-orb 20 | git-base-revision: <<#pipeline.git.base_revision>><><> 21 | -------------------------------------------------------------------------------- /.github/workflows/linux-ci.yml: -------------------------------------------------------------------------------- 1 | name: Linux CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [12.x, 14.x, 16.x, 17.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm run build --if-present 24 | - run: npm test -------------------------------------------------------------------------------- /.github/workflows/macos-ci.yml: -------------------------------------------------------------------------------- 1 | name: MacOS CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: macos-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [12.x, 14.x, 16.x, 17.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm run build --if-present 24 | - run: npm test -------------------------------------------------------------------------------- /.github/workflows/release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v3 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 16.x 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 16.x 23 | 24 | - name: Install Dependencies 25 | run: npm i 26 | 27 | - name: Create Release Pull Request / NPM Publish 28 | uses: changesets/action@v1 29 | with: 30 | publish: npm run publish-changeset 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/windows-ci.yml: -------------------------------------------------------------------------------- 1 | name: Windows CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: windows-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [12.x, 14.x, 16.x, 17.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm run build --if-present 24 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | generate-errors.js 4 | jest.config.js -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## vNEXT 4 | 5 | > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. 6 | 7 | ## v0.3 8 | 9 | - Significant API changes (see [README](./README.md)) 10 | - Support for `@link(url:, import:)`. Reading schemas with legacy `@core` directives is still supported. 11 | - Compile definitions into schemas. Use `schema.compile(atlas?)` to copy necessary definitions from `atlas` into `schema` 12 | - Report non-fatal errors with `report`, get them with `getResult`. Example: `getResult(() => runMyValidations(schema.compile(atlas))).errors()` will yield all errors which occurred during compilation or validation. 13 | - Add support for Node 17 [PR #41](https://github.com/apollographql/core-schema-js/pull/41) 14 | 15 | ## v0.2.2 16 | - Don't call `GraphQLError.toString()` recursively [PR #36](https://github.com/apollographql/core-schema-js/pull/36) 17 | 18 | ## v0.2.1 19 | 20 | - Add support for graphql@16 [PR #19](https://github.com/apollographql/core-schema-js/pull/19) 21 | 22 | ## v0.2.0 23 | 24 | - __BREAKING__: Update graphql dev and peerDependency and fix `GraphQLError` usage. Update name assignment and remove name getter method [#20](https://github.com/apollographql/core-schema-js/pull/20) 25 | 26 | ## v0.1.1 27 | 28 | - Remove unnecessary `engines` specification for `npm` which limited it to only working on `npm@7`. The spirit of that specificity was to provide a hint to _maintainers_ as to what version of `npm` should be used to generate the `package-lock.json` file and reduce churn on that file which happened between npm@6 and npm@7. Of course, while this was effective and harmless in the `federation` monorepo (from which this was copied and pasted from), it obviously has implications on consumers in published packages. Fixed via [`ee1a330e`](https://github.com/apollographql/core-schema-js/commit/ee1a330e2f2c3f8b45a4526caf3bf4b3a4de4f7a). 29 | 30 | ## v0.1.0 31 | 32 | - Initial Release 🎉 -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by the Apollo SecOps team 2 | # Please customize this file as needed prior to merging. 3 | 4 | * @apollographql/atlas 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Apollo Graph, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @apollo/core-schema 2 | 3 | *typescript library for processing core schemas* 4 | 5 | to install via npm: 6 | 7 | ```sh 8 | npm install @apollo/core-schema 9 | ``` 10 | 11 | to build from source: 12 | 13 | ```sh 14 | npm install 15 | npm test 16 | ``` 17 | 18 | # quickly 19 | 20 | ## parse a schema 21 | 22 | ```typescript 23 | import { Schema, gql } from '@apollo/core-schema' 24 | 25 | const schema = Schema.basic(gql`${"example.graphql"} 26 | @link(url: "https://specs.apollo.dev/federation/v1.0") 27 | @link(url: "https://specs.apollo.dev/inaccessible/v0.1") 28 | 29 | type User @inaccessible { 30 | id: ID! 31 | } 32 | `); 33 | 34 | expect([...schema]).toMatchInlineSnapshot(` 35 | Array [ 36 | <>[GraphQL request] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 37 | <#User>[GraphQL request] 👉type User @inaccessible {, 38 | ] 39 | `); 40 | 41 | expect([...schema.scope]).toMatchInlineSnapshot() 42 | 43 | expect([...schema.refs]).toMatchInlineSnapshot(` 44 | Array [ 45 | <>[example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 46 | [example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 47 | [example.graphql] 👉@link(url: "https://specs.apollo.dev/inaccessible/v0.1"), 48 | <#User>[example.graphql] 👉type User @inaccessible {, 49 | [example.graphql] type User 👉@inaccessible {, 50 | <#ID>[example.graphql] id: 👉ID!, 51 | ] 52 | `); 53 | ``` 54 | 55 | 56 | ## look for directives by their global graph position 57 | 58 | ```typescript 59 | import {Schema, Defs, GRef, directives} from '@apollo/core-schema' 60 | 61 | const schema = Schema.basic(gql ` 62 | extend schema 63 | @link(url: "https://spec.example.io/hidden/v1.0", as: "private") 64 | 65 | type Product 66 | type Admin @private 67 | type User 68 | `) 69 | 70 | const HIDDEN = GRef.rootDirective('https://spec.example.io/hidden/v1.0') 71 | function *hiddenDefs(defs: Defs) { 72 | for (const def of defs) { 73 | for (const directive of directives(def)) { 74 | if (directive.gref === HIDDEN) { 75 | yield def 76 | break 77 | } 78 | } 79 | } 80 | } 81 | 82 | expect([...hiddenDefs(schema)].map(def => def.name)) 83 | .toEqual(['Admin']) 84 | ``` 85 | 86 | ## lookup names in a core schema 87 | 88 | get a `Schema` from a document with `Schema.from` and then 89 | look up document names via `schema.scope`: 90 | 91 | ```typescript 92 | import {Schema, GRef, ref} from '@apollo/core-schema' 93 | 94 | const doc = Schema.from(gql ` 95 | extend schema 96 | @link(url: "https://specs.apollo.dev/link/v1.0") 97 | @link(url: "https://example.com/someSpec/v1.0") 98 | @link(url: "https://spec.example.io/another/v1.0", as: "renamed") 99 | `) 100 | expect(doc.scope.lookup('@link')).toBe( 101 | GRef.rootDirective('https://specs.apollo.dev/link/v1.0') 102 | ) 103 | expect(doc.scope.lookup('renamed__Type'))).toBe( 104 | GRef.named('Type', "https://spec.example.io/another/v1.0") 105 | ) 106 | ``` 107 | 108 | ## build a document with implicit scope 109 | 110 | it's often useful to interpret a document with a set of builtin 111 | links already in scope. 112 | 113 | `Scope.from` takes a second argument—the so-called `frame`—to 114 | enable this: 115 | 116 | ```typescript 117 | const SUBGRAPH_BUILTINS = Schema.from(gql ` 118 | extend schema 119 | @link(url: "https://specs.apollo.dev/link/v1.0") 120 | @link(url: "https://specs.apollo.dev/federation/v1.0", 121 | import: "@key @requires @provides @external") 122 | `) 123 | 124 | function subgraph(document: DocumentNode) { 125 | return Schema.from(document, SUBGRAPH_BUILTINS) 126 | } 127 | 128 | subgraph(gql ` 129 | # @key in the next line will be linked to: 130 | # 131 | # https://specs.apollo.dev/federation/v1.0#@key 132 | type User @key(field: "id") { 133 | id: ID! 134 | } 135 | `) 136 | 137 | subgraph(gql ` 138 | # this will shadow the built-in link to @key: 139 | extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", 140 | import: "@key") 141 | 142 | # @key in the next line will be linked to: 143 | # 144 | # https://specs.apollo.dev/federation/v2.0#@key 145 | type User @key(field: "id") { 146 | id: ID! 147 | }`) 148 | ``` 149 | 150 | ## iterate over links from a document 151 | ```typescript 152 | function linksFed2(doc: Schema) { 153 | for (const link of doc.scope) { 154 | if (link.gref.graph.satisfies(LinkUrl.from("https://specs.apollo.dev/federation/v2.0"))) { 155 | // child links federation 2.0 156 | return true 157 | } 158 | } 159 | return false 160 | } 161 | 162 | expect( 163 | linksFed2(Schema.basicFrom(gql ` 164 | extend schema @link(url: "https://specs.apollo.dev/federation/v2.0") 165 | `)) 166 | ).toBe(true) 167 | 168 | expect( 169 | linksFed2(Schema.basicFrom(gql ` 170 | extend schema @link(url: "https://specs.apollo.dev/federation/v1.9") 171 | `)) 172 | ).toBe(false) 173 | 174 | expect( 175 | linksFed2(Schema.basicFrom(gql ``)) 176 | ).toBe(false) 177 | ``` 178 | 179 | ## standardize names within a document 180 | 181 | perhaps you want to scan directives in a document without having to worry about whether the user has renamed them. 182 | 183 | the `schema.standardize(...urls)` method can help: 184 | 185 | ```typescript 186 | const subgraph = Schema.basic(gql ` 187 | @link(url: "https://specs.apollo.dev/federation/v2.0", 188 | # what weird naming choices! 189 | import: """ 190 | @key (as @fkey) 191 | @requires (as @frequires) 192 | @provides (as @fprovides) 193 | @tag (as @ftag) 194 | """) 195 | 196 | type User @fkey(fields: "id") { 197 | id: ID! @ftag(name: "hi") @tag(name: "my tag") 198 | } 199 | 200 | # note: this is our *own* @tag directive, which looks 201 | # just like but means something different than 202 | # federation's @tag: 203 | directive @tag(name: string) on FIELD_DEFINITION 204 | `); 205 | 206 | expect( 207 | raw( 208 | // standardize takes LinkUrls and ensures that all references to that schema 209 | // are prefixed with its standard name 210 | subgraph.standardize("https://specs.apollo.dev/federation/v2.0").print() 211 | ) 212 | ).toMatchInlineSnapshot(` 213 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/id/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.0") 214 | 215 | type User @federation__key(fields: "id") { 216 | id: ID! @federation__tag(name: "hi") @tag(name: "my tag") 217 | } 218 | 219 | directive @tag(name: string) on FIELD_DEFINITION 220 | `); 221 | ``` 222 | 223 | # motivation 224 | 225 | this library exists to help you read and manipulate core schemas. 226 | ## background 227 | 228 | [core schemas](https://specs.apollo.dev/core/v0.2) can reference elements from one another. 229 | 230 | for example, this schema references federation 2.0 and uses the `@key` directive from it: 231 | 232 | ```graphql 233 | extend schema 234 | @link(url: "https://specs.apollo.dev/federation/v2.0") 235 | 236 | type User @federation__key(fields: "id") { 237 | id: ID! 238 | } 239 | ``` 240 | 241 | here, we link the `federation` spec by its url. this links the name `federation` to the url `https://specs.apollo.dev/federation/v2.0`, instructing core-aware processors that identifiers like `federation__FieldSet` and `@federation__key` are defined by https://specs.apollo.dev/federation/v2.0. 242 | 243 | `@link` inferred the name `federation` (and also the version `2.0`) from the url. you can also set the name explicitly: 244 | 245 | ```graphql 246 | extend schema 247 | @link(url: "https://specs.apollo.dev/federation/v2.0", as: fedv2) 248 | 249 | type User @fedv2__key(fields: "id") { 250 | id: ID! 251 | } 252 | ``` 253 | 254 | these namespaced names can get annoying, so `@link` also provides an `import` argument, which links unprefixed names to remote definitions: 255 | 256 | ```graphql 257 | extend schema 258 | @link(url: "https://specs.apollo.dev/federation/v2.0", 259 | import: "@key") 260 | 261 | type User @key(fields: "id") { 262 | id: ID! 263 | } 264 | ``` 265 | 266 | the lets us fix name conflicts. for example, say i have this schema: 267 | 268 | ```graphql 269 | type User @key(column: "id") { 270 | id: ID! 271 | } 272 | 273 | directive @key(column: string) on OBJECT 274 | ``` 275 | 276 | now say i want to make this schema a federation subgraph. federation already defines a `@key` directive; it will conflict with my own `@key` directive, which is unrelated. 277 | 278 | with `@link`, i can give federation's `@key` directive any name i want, avoiding the conflict: 279 | 280 | ```graphql 281 | extend schema 282 | @link(url: "https://specs.apollo.dev/federation/v2.0", 283 | import: "@fedKey: @key") 284 | 285 | type User @fedKey(fields: "id") @key(column: "id") { 286 | id: ID! 287 | } 288 | 289 | directive @key(column: string) on OBJECT 290 | ``` 291 | 292 | note that this also works for the `@link` directive itself: 293 | 294 | ```graphql 295 | extend schema 296 | @coreLink(url: "https://specs.apollo.dev/link/v1.0", as: coreLink) 297 | @coreLink(url: "https://specs.apollo.dev/federation/v2.0", 298 | import: "@fedKey: @key") 299 | ``` 300 | 301 | 302 | ## compilation 303 | 304 | the examples above are not valid GraphQL schemas because they do not contain definitions of all the elements they name. specifically, they don't contain definitions of the federation directives, nor of `@link` itself. if you feed them to a tool which expects a valid GraphQL schema, that tool will break. 305 | 306 | it seems like we should be able to fix this. `@link` strongly resembles an `import` statement—its existence seems to imply some compilation process which can somehow look up the relevant definitions and insert them into the document. 307 | 308 | this library provides such a mechanism. along the way, it provides a framework for working with *global graph* definitions—constructing schemas out of them, copying them from one document to another, and so on. 309 | 310 | ### the compiler's problem 311 | 312 | take this schema again: 313 | 314 | ```graphql 315 | extend schema 316 | @link(url: "https://specs.apollo.dev/federation/v2.0", 317 | import: "@fedKey: @key") 318 | 319 | type User @fedKey(fields: "id") { 320 | id: ID! 321 | } 322 | ``` 323 | 324 | the compiler has to look at this schema and insert definitions for any elements which are referenced but not defined in the document. say we have an atlas with one schema in it: 325 | 326 | ```graphql 327 | extend schema 328 | # @id is @link's sister, specifying this schema's 329 | # position within the global graph 330 | @id(url: "https://specs.apollo.dev/federation/v2.0") 331 | 332 | directive @key(fields: FieldSet!) on OBJECT 333 | scalar FieldSet 334 | ``` 335 | 336 | the compiler needs to copy the definition for `@key` into the document. and then it also needs to copy the definition for `FieldSet`, since `@key` references `FieldSet`. and when it inserts these definitions into the document, it needs to change their names to fit the namespace of the document. core schemas can transitively `@link` other core schemas, so this may involve adding `@link`s to other schemas as well. 337 | 338 | this library exposes an editing model designed to make this tricky task—and others like it—much easier. 339 | 340 | # editing model 341 | 342 | the basic approach is: 343 | 1. read a schema and construct its scope by examining its `@link` directives. the scope manages the namespace—it is able to look at any definition or reference in the document and associate it with a global graph position (a url, essentially). the scope is completely unconcerned with whether a given element has a definition within the document—its only job is to associate names with urls. 344 | 2. when copying nodes out of a document, annotate those nodes and their descendants with their global graph positions. we call this process *detachment* or *denormalization* (because the metadata carried by the `@link` directives has been denormalized into the entire tree). 345 | 3. move definitions around as needed without worrying about namespaces 346 | 4. before emitting a finished document, collect all its references, generate appropriate `@link` headers, and *renormalize* all its nodes, setting their names as appropriate. 347 | 348 | the process of denormalizing and renormalizing nodes is mostly transparent. 349 | 350 | ## in practice 351 | 352 | you can construct a `Schema` from a GraphQL document like so: 353 | 354 | ```typescript 355 | import {Schema, gql} from '@apollo/core-schema' 356 | 357 | const schema = Schema.from(gql ` 358 | extend schema 359 | @id(url: "https://my/schema") 360 | @link(url: "https://specs.apollo.dev/link/v1.0") 361 | @link(url: "https://specs.apollo.dev/federation", import: "@key") 362 | @link(url: "https://myorg.internal/future") 363 | 364 | type User @key(fields: "id") @future 365 | `) 366 | ``` 367 | 368 | `Schema`s are iterable, yielding each of the definitions in the document: 369 | 370 | ```typescript 371 | const defs = [...schema] 372 | ``` 373 | 374 | `Schema`s always yield detached subtrees. definitions and references in a detached subtree have a `.gref` property, which locates the node within the global graph: 375 | 376 | ```typescript 377 | import {GRef} from '@apollo/core-schema' 378 | 379 | expect(defs[defs.length - 1].gref).toBe( 380 | GRef.named('User', 'https://my/schema') 381 | ) 382 | ``` 383 | 384 | (an "gref" is an "href" for the "g"raph). 385 | 386 | you can insert detached nodes into the document using whatever mechanism: 387 | 388 | ```typescript 389 | // helper to create a detached @tag directive 390 | function $tag(name: string) { 391 | return { 392 | kind: Kind.DIRECTIVE, 393 | name: "tag", 394 | arguments: [{ 395 | name: { kind: Kind.NAME, value: "name" }, 396 | value: { kind: Kind.STRING, value: name } 397 | }], 398 | gref: GRef.rootDirective("https://specs.apollo.dev/tag/v0.1") 399 | } 400 | ]) 401 | 402 | // replace @future with @tag(name: "future") 403 | const newSchema = schema.mapDoc(schema => 404 | visit(schema.document, { 405 | Directive(node) { 406 | if (!hasRef(node)) return 407 | if (node.gref === GRef.rootDirective("https://myorg.internal/future")) { 408 | // replace @future with @tag(name: "future") 409 | return $tag("future") 410 | } 411 | } 412 | })) 413 | ``` 414 | 415 | finally, we can call `compile` to renormalize everything and ensure the appropriate `@link` headers are present: 416 | 417 | ```typescript 418 | return newSchema.compile() 419 | ``` 420 | 421 | `schema.compile()` takes an optional argument, an `atlas` from which it will try to fill any definitions which are referenced but not present in the document. `atlas` can be any iterable over detached definitions. for example, it can be another `Schema`: 422 | 423 | ```typescript 424 | const tagSchema = Schema.basic(gql` 425 | @id(url: "https://specs.apollo.dev/tag/v0.1") 426 | directive @tag(name: string) repeatable on OBJECT 427 | `) 428 | return newSchema.compile(tagSchema) 429 | ``` 430 | 431 | you can use the `Atlas` class to join multiple schemas together into an atlas. 432 | 433 | ## design principles 434 | 435 | ### AST-focused 436 | 437 | this library takes an AST-focused approach to working with schemas. 438 | 439 | this is nice because the AST can represent many situations which cannot be represented with a `GraphQLSchema`. for example, schemas which do not contain all their definitions (a principle motivation for this library!) cannot be represented in the `GraphQL*` class structure. thus, we just don't try: this library never calls `buildSchema`, nor do we touch execution-focused classes like `GraphQLSchema`. 440 | 441 | additionally, working with the AST gives us the ability to make *small* changes to the document without radically changing the structure. by default, operations implemented here try to make minimal changes to the document, preserving its structure as well as possible. alas, limitations in the graphql parser mean that we cannot currently preserve comments. 442 | 443 | finally, AST nodes are given a source position by the parser and retain that position even across complex transforms. this helps with error reporting, and would also make it relatively easy to generate sourcemaps, though we do not currently do this. 444 | 445 | ### pure and immutable 446 | 447 | essentially this whole library is implemented as pure functions on immutable data structures, starting with ASTNodes (which we treat as immutable). expensive operations are memoized. 448 | 449 | ### lazy 450 | 451 | a consequence of the pure/immutable/memoized design is that we generally do not compute anything until we need it. for example, `Schema.from` does not even scan the document's `@link`s and construct a scope until `schema.scope` is actually used. similarly, nodes are not denormalized until they are accessed. 452 | 453 | ### canonized value types 454 | 455 | a few types—notably `GRef`, `LinkUrl`, and `Version`—are *canonized*. that is, they can only be created via a memoized function, which ensures that two equivalent instances will always be the same instance: 456 | 457 | ```typescript 458 | expect(LinkUrl.from('https://specs/example/?extraneous&stuff&ignored')) 459 | .toBe(LinkUrl.from('https://specs/example')) 460 | ``` 461 | 462 | these are effectively value types, and they can be (and are) used e.g. as keys in `Map`s. -------------------------------------------------------------------------------- /generate-errors.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const readdir = require('util').promisify(fs.readdir) 4 | const write = require('util').promisify(fs.writeFile) 5 | const exec = require('util').promisify(require('child_process').exec) 6 | 7 | const DIR = path.resolve(process.argv[2]) || __dirname 8 | const OUTPUT = path.join(DIR, 'src', 'errors.ts') 9 | 10 | const allErrors = new Map 11 | 12 | async function main() { 13 | for await (const file of walk(path.join(DIR, 'dist'))) { 14 | if (!file.endsWith('.js')) continue 15 | 16 | // the index file re-exports some errors, ignore it 17 | if (file === 'index.js') continue 18 | 19 | const modulePath = path.join(DIR, 'dist', path.basename(file, '.js')) 20 | let sourcePath = './' + modulePath.slice(path.join(DIR, 'dist').length + 1) 21 | if (sourcePath.endsWith('/index')) 22 | sourcePath = sourcePath.slice(0, sourcePath.length - '/index'.length) 23 | const mod = require(file) 24 | const errors = Object.keys(mod) 25 | .filter(name => name.startsWith('Err')) 26 | .map(err => err.slice('Err'.length)) 27 | 28 | if (!errors.length) continue 29 | 30 | for (const code of errors) { 31 | const fn = mod['Err' + code] 32 | const existing = allErrors.get(code) 33 | if (existing) { 34 | if (existing.fn !== fn) 35 | throw new Error(`error code ${code} is defined in multiple modules: ${sourcePath} and ${existing}`) 36 | if (existing.path.length > sourcePath.length) 37 | allErrors.set(code, {path: sourcePath, fn: mod['Err' + code]}) 38 | } else { 39 | allErrors.set(code, {path: sourcePath, fn: mod['Err' + code]}) 40 | } 41 | } 42 | } 43 | 44 | const allModules = new Map 45 | for (const [code, {path}] of allErrors) { 46 | if (!allModules.has(path)) allModules.set(path, []) 47 | allModules.get(path).push(code) 48 | } 49 | 50 | if (!allErrors.size) { 51 | console.warn('no error codes found, errors.ts not written') 52 | return 53 | } 54 | 55 | const allCodes = [...allErrors.keys()] 56 | await write(OUTPUT, 57 | `// autogenerated by ../generate-errors.js 58 | // regenerate when new error types are added anywhere in the project. 59 | // to regenerate: npm run build && node ./generate-errors 60 | 61 | ${[...allModules].map(([path, codes]) => 62 | `import { ${codes.map(code => 'Err' + code).join(', ')} } from "${path}"` 63 | ).join('\n')} 64 | 65 | export type AnyError = ReturnType<${ 66 | allCodes 67 | .map(code => 'typeof Err' + code) 68 | .join('|') 69 | }> 70 | 71 | const ERROR_CODES = new Set(${JSON.stringify(allCodes)}) 72 | 73 | export function isAnyError(o: any): o is AnyError { 74 | return ERROR_CODES.has(o?.code) 75 | } 76 | `) 77 | await exec(`npx prettier -w "${OUTPUT}"`) 78 | } 79 | 80 | main().catch(err => { 81 | console.error(err) 82 | process.exit(1) 83 | }) 84 | 85 | async function* walk(dir) { 86 | for await (const d of await fs.promises.opendir(dir)) { 87 | const entry = path.join(dir, d.name) 88 | if (d.isDirectory()) yield* walk(entry) 89 | else if (d.isFile()) yield entry 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { defaults } = require("jest-config"); 2 | 3 | module.exports = { 4 | testEnvironment: "node", 5 | preset: "ts-jest", 6 | testMatch: null, 7 | testRegex: ".*\\.test\\.(js|ts)$", 8 | testPathIgnorePatterns: [ 9 | "/node_modules/", 10 | "/dist/" 11 | ], 12 | snapshotSerializers: [ 13 | ...defaults.snapshotSerializers, 14 | './src/snapshot-serializers/ast.ts', 15 | './src/snapshot-serializers/raw.ts', 16 | './src/snapshot-serializers/gref.ts', 17 | './src/snapshot-serializers/iterable.ts', 18 | './src/snapshot-serializers/redirect.ts', 19 | ], 20 | moduleFileExtensions: [...defaults.moduleFileExtensions, "ts", "tsx"], 21 | globals: { 22 | "ts-jest": { 23 | tsconfig: "/tsconfig.test.json", 24 | diagnostics: false 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apollo/core-schema", 3 | "version": "0.3.0", 4 | "description": "Apollo Core Schema processing library", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/apollographql/core-schema-js.git" 10 | }, 11 | "scripts": { 12 | "build": "tsc -p .", 13 | "watch": "tsc -p . -w", 14 | "test": "jest", 15 | "watch:test": "jest --watch", 16 | "prepare": "npm run build", 17 | "publish-changeset": "changeset publish" 18 | }, 19 | "keywords": [ 20 | "graphql", 21 | "federation", 22 | "apollo" 23 | ], 24 | "author": "Apollo ", 25 | "license": "MIT", 26 | "engines": { 27 | "node": ">=12.13.0" 28 | }, 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "dependencies": { 33 | "@protoplasm/recall": "^0.2" 34 | }, 35 | "peerDependencies": { 36 | "graphql": "^15 || ^16" 37 | }, 38 | "devDependencies": { 39 | "@changesets/changelog-github": "^0.4.4", 40 | "@changesets/cli": "^2.22.0", 41 | "@types/jest": "27.5.0", 42 | "graphql": "16.4.0", 43 | "jest": "28.1.0", 44 | "jest-config": "28.1.0", 45 | "prettier": "2.6.2", 46 | "ts-jest": "28.0.2", 47 | "typescript": "4.6.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | // Our default configuration. See 4 | // https://github.com/apollographql/renovate-config-apollo-open-source/blob/master/package.json 5 | "apollo-open-source", 6 | // Bundle together Jest/TS-Jest updates (even major ones). 7 | "group:jestMonorepo", 8 | "group:jestPlusTSJest", 9 | ], 10 | "dependencyDashboard": true, 11 | "enabledManagers": ["npm"], 12 | "packageRules": [ 13 | // We set this to the lowest supported Node.js version to ensure we don't 14 | // use newer Node.js APIs unknowingly during development which are going to 15 | // fail in CI anyway when they're run against the full range of Node.js 16 | // versions we support. 17 | { 18 | "matchPackageNames": ["@types/node"], 19 | "allowedVersions": "14.x" 20 | }, 21 | // Bunch up all non-major npm dependencies into a single PR. In the common case 22 | // where the upgrades apply cleanly, this causes less noise and is resolved faster 23 | // than starting a bunch of upgrades in parallel for what may turn out to be 24 | // a suite of related packages all released at once. 25 | { 26 | groupName: "all non-major dependencies", 27 | matchUpdateTypes: ["patch", "minor"], 28 | groupSlug: "all-npm-minor-patch", 29 | matchManagers: [ "npm" ], 30 | }, 31 | ], 32 | "force": { 33 | "constraints": { 34 | "node": ">= 16.0", 35 | "npm": ">= 7.0" 36 | } 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/__tests__/de.test.ts: -------------------------------------------------------------------------------- 1 | import { getResult } from "@protoplasm/recall"; 2 | import { parse, Source } from "graphql"; 3 | import { fill, refNodesIn } from "../de"; 4 | import gql from "../gql"; 5 | import GRef from "../gref"; 6 | import Schema from "../schema"; 7 | import raw from "../snapshot-serializers/raw"; 8 | 9 | const base = Schema.from( 10 | parse( 11 | new Source( 12 | ` 13 | extend schema 14 | @link(url: "https://specs.apollo.dev/link/v1.0") 15 | @link(url: "https://specs.apollo.dev/id/v1.0") 16 | 17 | directive @link(url: link__Url!, as: link__Schema, import: link__Import) 18 | repeatable on SCHEMA 19 | directive @id(url: link__Url!, as: link__Schema) on SCHEMA 20 | `, 21 | "builtins.graphql" 22 | ) 23 | ) 24 | ); 25 | 26 | const schema = Schema.from( 27 | parse( 28 | new Source( 29 | ` 30 | extend schema 31 | @id(url: "https://specs/me") 32 | @link(url: "https://specs.apollo.dev/federation/v2.0", 33 | import: "@requires @key @prov: @provides") 34 | 35 | type User @key(fields: "id") { 36 | id: ID! 37 | } 38 | 39 | directive @key(fields: String) on OBJECT 40 | `, 41 | "example" 42 | ) 43 | ), 44 | base 45 | ); 46 | 47 | describe("fill", () => { 48 | it("fills definitions", () => { 49 | expect(fill(schema, base)).toMatchInlineSnapshot(` 50 | Iterable [ 51 | [builtins.graphql] 👉directive @id(url: link__Url!, as: link__Schema) on SCHEMA, 52 | [builtins.graphql] 👉directive @link(url: link__Url!, as: link__Schema, import: link__Import), 53 | ] 54 | `); 55 | }); 56 | 57 | it("reports errors", () => { 58 | const result = getResult(() => [...fill(schema, base)]); 59 | expect([...result.errors()].map((err: any) => [err.code, err.nodes])) 60 | .toMatchInlineSnapshot(` 61 | Array [ 62 | Array [ 63 | "NoDefinition", 64 | Array [ 65 | [example] 👉@link(url: "https://specs.apollo.dev/federation/v2.0", 66 | ], 67 | ], 68 | Array [ 69 | "NoDefinition", 70 | Array [ 71 | [example] 👉@link(url: "https://specs.apollo.dev/federation/v2.0", 72 | ], 73 | ], 74 | Array [ 75 | "NoDefinition", 76 | Array [ 77 | [example] id: 👉ID!, 78 | ], 79 | ], 80 | Array [ 81 | "NoDefinition", 82 | Array [ 83 | [example] directive @key(fields: 👉String) on OBJECT, 84 | ], 85 | ], 86 | Array [ 87 | "NoDefinition", 88 | Array [ 89 | [builtins.graphql] directive @id(url: 👉link__Url!, as: link__Schema) on SCHEMA, 90 | [builtins.graphql] directive @link(url: 👉link__Url!, as: link__Schema, import: link__Import), 91 | ], 92 | ], 93 | Array [ 94 | "NoDefinition", 95 | Array [ 96 | [builtins.graphql] directive @id(url: link__Url!, as: 👉link__Schema) on SCHEMA, 97 | [builtins.graphql] directive @link(url: link__Url!, as: 👉link__Schema, import: link__Import), 98 | ], 99 | ], 100 | Array [ 101 | "NoDefinition", 102 | Array [ 103 | [builtins.graphql] directive @link(url: link__Url!, as: link__Schema, import: 👉link__Import), 104 | ], 105 | ], 106 | ] 107 | `); 108 | }); 109 | }); 110 | 111 | describe("refsInDefs", () => { 112 | it("finds deep references", () => { 113 | const schema = Schema.from( 114 | parse(` 115 | extend schema 116 | @link(url: "https://specs.apollo.dev/federation/v2.0", 117 | import: "@requires @key @prov: @provides") 118 | @link(url: "file:../common", import: "Filter") 119 | 120 | type User @key(fields: "id") @federation { 121 | favorites(filter: Filter): [Favorite] @requires(fields: "prefs") 122 | } 123 | `), 124 | base 125 | ); 126 | const User = schema.definitions(GRef.named("User")); 127 | expect([...refNodesIn(User)]).toMatchInlineSnapshot(` 128 | Array [ 129 | <#User>[GraphQL request] 👉type User @key(fields: "id") @federation {, 130 | [GraphQL request] type User 👉@key(fields: "id") @federation {, 131 | [GraphQL request] type User @key(fields: "id") 👉@federation {, 132 | [GraphQL request] favorites(filter: 👉Filter): [Favorite] @requires(fields: "prefs"), 133 | <#Favorite>[GraphQL request] favorites(filter: Filter): [👉Favorite] @requires(fields: "prefs"), 134 | [GraphQL request] favorites(filter: Filter): [Favorite] 👉@requires(fields: "prefs"), 135 | ] 136 | `); 137 | }); 138 | }); 139 | 140 | describe("a subgraph test", () => { 141 | it("works", () => { 142 | const schema = Schema.basic(gql`${"subgraph-test.graphql"} 143 | extend schema 144 | @link(url: "https://specs.apollo.dev/link/v1.0") 145 | @link(url: "https://specs.apollo.dev/federation/v1.0", 146 | import: "@key @requires @provides @external") 147 | @link(url: "https://specs.apollo.dev/id/v1.0") 148 | 149 | type Query { 150 | product: Product 151 | } 152 | 153 | type Product @key(fields: "upc") { 154 | upc: String! 155 | name: String 156 | } 157 | 158 | extend type Product { 159 | price: Int 160 | } 161 | 162 | directive @key(fields: federation__FieldSet!) repeatable on OBJECT 163 | 164 | scalar federation__FieldSet 165 | `); 166 | expect([...refNodesIn(schema)]).toMatchInlineSnapshot(` 167 | Array [ 168 | GRef <#@key> => GRef (via [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 169 | GRef <#@requires> => GRef (via [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 170 | GRef <#@provides> => GRef (via [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 171 | GRef <#@external> => GRef (via [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 172 | <>[subgraph-test.graphql] 👉extend schema, 173 | [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 174 | [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0", 175 | [subgraph-test.graphql] 👉@link(url: "https://specs.apollo.dev/id/v1.0"), 176 | <#Query>[subgraph-test.graphql] 👉type Query {, 177 | <#Product>[subgraph-test.graphql] product: 👉Product, 178 | <#Product>[subgraph-test.graphql] 👉type Product @key(fields: "upc") {, 179 | [subgraph-test.graphql] type Product 👉@key(fields: "upc") {, 180 | [subgraph-test.graphql] upc: 👉String!, 181 | [subgraph-test.graphql] name: 👉String, 182 | <#Product>[subgraph-test.graphql] 👉extend type Product {, 183 | [subgraph-test.graphql] price: 👉Int, 184 | [subgraph-test.graphql] 👉directive @key(fields: federation__FieldSet!) repeatable on OBJECT, 185 | [subgraph-test.graphql] directive @key(fields: 👉federation__FieldSet!) repeatable on OBJECT, 186 | [subgraph-test.graphql] 👉scalar federation__FieldSet, 187 | ] 188 | `); 189 | 190 | const LINK = Schema.basic(gql`${"builtin/link/v1.0.graphql"} 191 | @id(url: "https://specs.apollo.dev/link/v1.0") 192 | 193 | directive @link(url: Url!, as: Name, import: Imports) 194 | repeatable on SCHEMA 195 | 196 | scalar Url 197 | scalar Name 198 | scalar Imports 199 | `); 200 | 201 | expect([...fill(schema, LINK)]).toMatchInlineSnapshot(` 202 | Array [ 203 | [builtin/link/v1.0.graphql] 👉directive @link(url: Url!, as: Name, import: Imports), 204 | [builtin/link/v1.0.graphql] 👉scalar Url, 205 | [builtin/link/v1.0.graphql] 👉scalar Name, 206 | [builtin/link/v1.0.graphql] 👉scalar Imports, 207 | ] 208 | `); 209 | 210 | expect( 211 | [...getResult(() => [...fill(schema, LINK)]).errors()].map((x) => 212 | raw(x.toString()) 213 | ) 214 | ).toMatchInlineSnapshot(` 215 | Array [ 216 | [NoDefinition] no definitions found for reference: https://specs.apollo.dev/federation/v1.0#@requires 217 | 218 | subgraph-test.graphql:4:9 219 | 3 | @link(url: "https://specs.apollo.dev/link/v1.0") 220 | 4 | @link(url: "https://specs.apollo.dev/federation/v1.0", 221 | | ^ 222 | 5 | import: "@key @requires @provides @external"), 223 | [NoDefinition] no definitions found for reference: https://specs.apollo.dev/federation/v1.0#@provides 224 | 225 | subgraph-test.graphql:4:9 226 | 3 | @link(url: "https://specs.apollo.dev/link/v1.0") 227 | 4 | @link(url: "https://specs.apollo.dev/federation/v1.0", 228 | | ^ 229 | 5 | import: "@key @requires @provides @external"), 230 | [NoDefinition] no definitions found for reference: https://specs.apollo.dev/federation/v1.0#@external 231 | 232 | subgraph-test.graphql:4:9 233 | 3 | @link(url: "https://specs.apollo.dev/link/v1.0") 234 | 4 | @link(url: "https://specs.apollo.dev/federation/v1.0", 235 | | ^ 236 | 5 | import: "@key @requires @provides @external"), 237 | ] 238 | `); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /src/__tests__/error.test.ts: -------------------------------------------------------------------------------- 1 | import err, { GraphQLErrorExt } from '../error' 2 | 3 | describe("GraphQLErrorExt", () => { 4 | it("sets a code, name, and message", () => { 5 | const error = err('SomethingWentWrong', 'it is very bad') 6 | expect(error.name).toEqual("SomethingWentWrong"); 7 | expect(error.code).toEqual(error.name); 8 | expect(error.message).toEqual("it is very bad"); 9 | }); 10 | 11 | it("calling `toString` doesn't throw an error", () => { 12 | const error = new GraphQLErrorExt("CheckFailed", "Check failed"); 13 | expect(() => error.toString()).not.toThrow(); 14 | }); 15 | 16 | it("calling `toString` prints the error", () => { 17 | const error = new GraphQLErrorExt("CheckFailed", "Check failed"); 18 | expect(error.toString()).toMatchInlineSnapshot( 19 | `"[CheckFailed] Check failed"` 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/__tests__/link-url.test.ts: -------------------------------------------------------------------------------- 1 | import {LinkUrl} from '../link-url' 2 | import {GRef} from '../gref' 3 | 4 | describe('LinkUrl.parse', () => { 5 | it('parses urls with names and versions', () => { 6 | const url = LinkUrl.parse('https://specs.apollo.dev/federation/v2.0') 7 | expect(url.name).toBe('federation') 8 | expect(url.version).toEqual({ major: 2, minor: 0 }) 9 | expect(url.href).toBe('https://specs.apollo.dev/federation/v2.0') 10 | }) 11 | 12 | it('parses urls with version only', () => { 13 | const url = LinkUrl.parse('https://specs.apollo.dev/v2.0') 14 | expect(url.name).toBeUndefined() 15 | expect(url.version).toEqual({ major: 2, minor: 0 }) 16 | expect(url.href).toBe('https://specs.apollo.dev/v2.0') 17 | }) 18 | 19 | it('parses urls with name only', () => { 20 | const url = LinkUrl.parse('https://specs.apollo.dev/federation') 21 | expect(url.name).toBe('federation') 22 | expect(url.version).toBeUndefined() 23 | expect(url.href).toBe('https://specs.apollo.dev/federation') 24 | }) 25 | 26 | it('stops parsing at invalid versions', () => { 27 | const url = LinkUrl.parse('https://specs.apollo.dev/federation/v.xxx') 28 | expect(url.name).toBeUndefined() 29 | expect(url.version).toBeUndefined() 30 | expect(url.href).toBe('https://specs.apollo.dev/federation/v.xxx') 31 | }) 32 | 33 | it('does not accept invalid names', () => { 34 | const url = LinkUrl.parse('https://specs.apollo.dev/federation-/v2.4') 35 | expect(url.name).toBeUndefined() 36 | expect(url.version).toEqual({ major: 2, minor: 4 }) 37 | expect(url.href).toBe('https://specs.apollo.dev/federation-/v2.4') 38 | }) 39 | 40 | it('accepts non-http protocols', () => { 41 | const url = LinkUrl.parse('internal-proto:federation/v2.0') 42 | expect(url.name).toBe('federation') 43 | expect(url.version).toEqual({ major: 2, minor: 0 }) 44 | expect(url.href).toBe('internal-proto:federation/v2.0') 45 | }) 46 | }) 47 | 48 | describe('grefs', () => { 49 | it('are canonicalized', () => { 50 | expect(GRef.named('User')).toBe(GRef.named('User')) 51 | expect(GRef.directive('deprecated')).toBe(GRef.directive('deprecated')) 52 | expect(GRef.named('User', 'https://example.com/schema')) 53 | .toBe(GRef.named('User', 'https://example.com/schema')) 54 | expect(GRef.directive('requires', 'https://example.com/federation/v2.0')) 55 | .toBe(GRef.directive('requires', 'https://example.com/federation/v2.0')) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/__tests__/linker.test.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveNode, parse } from "graphql"; 2 | import { Linker } from "../linker"; 3 | import GRef from "../gref"; 4 | 5 | describe("Linker", () => { 6 | describe("synthesize", () => { 7 | const linker = Linker.bootstrap( 8 | dir('@link(url: "https://specs.apollo.dev/link/v1.0")') 9 | )!; 10 | 11 | it("does not reference a schema by name unless it has a link", () => { 12 | expect( 13 | linker.synthesize([ 14 | { 15 | name: "@key", 16 | gref: GRef.directive("key", "https://specs.apollo.dev/federation"), 17 | }, 18 | ]) 19 | ).toMatchInlineSnapshot(` 20 | Iterable [ 21 | [+] @link(url: "https://specs.apollo.dev/federation", import: ["@key"]), 22 | ] 23 | `); 24 | 25 | expect( 26 | linker.synthesize([ 27 | { 28 | name: "@key", 29 | gref: GRef.directive("key", "https://specs.apollo.dev/federation"), 30 | }, 31 | 32 | { 33 | name: "federation", 34 | gref: GRef.schema("https://specs.apollo.dev/federation"), 35 | }, 36 | ]) 37 | ).toMatchInlineSnapshot(` 38 | Iterable [ 39 | [+] @link(url: "https://specs.apollo.dev/federation", import: ["@key"]), 40 | ] 41 | `); 42 | 43 | expect( 44 | linker.synthesize([ 45 | { 46 | name: "@key", 47 | gref: GRef.directive("key", "https://specs.apollo.dev/federation"), 48 | }, 49 | 50 | { 51 | name: "fed", 52 | gref: GRef.schema("https://specs.apollo.dev/federation"), 53 | }, 54 | ]) 55 | ).toMatchInlineSnapshot(` 56 | Iterable [ 57 | [+] @link(url: "https://specs.apollo.dev/federation", as: "fed", import: ["@key"]), 58 | ] 59 | `); 60 | }); 61 | 62 | it("collects imports", () => { 63 | expect( 64 | linker.synthesize([ 65 | { 66 | name: "@key", 67 | gref: GRef.directive("key", "https://specs.apollo.dev/federation"), 68 | }, 69 | 70 | { 71 | name: "fed", 72 | gref: GRef.schema("https://specs.apollo.dev/federation"), 73 | }, 74 | 75 | { 76 | name: "Graph", 77 | gref: GRef.named("Graph", "https://specs.apollo.dev/join"), 78 | }, 79 | 80 | { 81 | name: "@joinType", 82 | gref: GRef.directive("type", "https://specs.apollo.dev/join"), 83 | }, 84 | ]) 85 | ).toMatchInlineSnapshot(` 86 | Iterable [ 87 | [+] @link(url: "https://specs.apollo.dev/federation", as: "fed", import: ["@key"]), 88 | [+] @link(url: "https://specs.apollo.dev/join", import: ["Graph", {name: "@type", as: "@joinType"}]), 89 | ] 90 | `); 91 | }); 92 | }); 93 | }); 94 | 95 | function dir(source: string): DirectiveNode { 96 | return (parse(`extend schema ` + source).definitions[0] as any) 97 | .directives![0]; 98 | } 99 | -------------------------------------------------------------------------------- /src/__tests__/renaming.test.ts: -------------------------------------------------------------------------------- 1 | import { Kind } from "graphql"; 2 | import gql from "../gql"; 3 | import LinkUrl from "../link-url"; 4 | import Schema, { pruneLinks } from "../schema"; 5 | import Scope from "../scope"; 6 | import raw from "../snapshot-serializers/raw"; 7 | 8 | describe("view of a schema", () => { 9 | const federation = Schema.basic(gql`${"federation-frame"} 10 | @link(url: "https://specs.apollo.dev/federation/v2.0") 11 | `); 12 | 13 | it("creates a schema view with particular names", () => { 14 | const subgraph = Schema.basic(gql`${"subgraph"} 15 | @link(url: "https://specs.apollo.dev/federation/v2.0", 16 | import: """ 17 | @fkey: @key 18 | @frequires: @requires 19 | @fprovides: @provides 20 | @ftag: @tag 21 | """) 22 | 23 | type User @fkey(fields: "id") { 24 | id: ID! @ftag(name: "hi") @tag(name: "my tag") 25 | } 26 | 27 | directive @tag(name: string) on FIELD_DEFINITION 28 | `); 29 | 30 | const FED2 = LinkUrl.from("https://specs.apollo.dev/federation/v2.0"); 31 | const newScope = Scope.create((scope) => { 32 | const flat = subgraph.scope.flat 33 | for (const link of flat) { 34 | if (link.gref.graph !== FED2) scope.add(link); 35 | } 36 | for (const link of federation.scope) scope.add(link); 37 | }); 38 | const output = Schema.from({ 39 | kind: Kind.DOCUMENT, 40 | definitions: [ 41 | ...newScope.renormalizeDefs([...newScope.header(), ...pruneLinks(subgraph)]), 42 | ], 43 | }); 44 | expect(raw(output.print())).toMatchInlineSnapshot(` 45 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/id/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.0") 46 | 47 | type User @federation__key(fields: "id") { 48 | id: ID! @federation__tag(name: "hi") @tag(name: "my tag") 49 | } 50 | 51 | directive @tag(name: string) on FIELD_DEFINITION 52 | `); 53 | 54 | 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/__tests__/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { Kind, parse, Source, print } from "graphql"; 2 | import { Locatable, refNodesIn } from "../de"; 3 | import gql from "../gql"; 4 | import { GRef } from "../gref"; 5 | import LinkUrl from "../link-url"; 6 | import Schema from "../schema"; 7 | import { Atlas } from "../atlas"; 8 | import raw from "../snapshot-serializers/raw"; 9 | import { getResult } from "@protoplasm/recall"; 10 | 11 | const base = Schema.from( 12 | parse( 13 | new Source( 14 | ` 15 | extend schema 16 | @link(url: "https://specs.apollo.dev/link/v1.0") 17 | @link(url: "https://specs.apollo.dev/id/v1.0") 18 | 19 | directive @link(url: link__Url!, as: link__Schema, import: link__Import) 20 | repeatable on SCHEMA 21 | directive @id(url: link__Url!, as: link__Schema) on SCHEMA 22 | `, 23 | "builtins.graphql" 24 | ) 25 | ) 26 | ); 27 | 28 | describe("Schema", () => { 29 | it("a basic schema", () => { 30 | const schema = Schema.basic(gql`${"example.graphql"} 31 | @link(url: "https://specs.apollo.dev/federation/v1.0") 32 | @link(url: "https://specs.apollo.dev/inaccessible/v0.1") 33 | 34 | type User @inaccessible { 35 | id: ID! 36 | } 37 | `); 38 | 39 | expect(schema).toMatchInlineSnapshot(` 40 | Schema [ 41 | <>[example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 42 | <#User>[example.graphql] 👉type User @inaccessible {, 43 | ] 44 | `); 45 | 46 | expect(schema.scope).toMatchInlineSnapshot(` 47 | Scope [ 48 | Object { 49 | "gref": GRef , 50 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 51 | "name": "federation", 52 | "via": [example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 53 | }, 54 | Object { 55 | "gref": GRef , 56 | "implicit": true, 57 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 58 | "name": "@federation", 59 | "via": [example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 60 | }, 61 | Object { 62 | "gref": GRef , 63 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 64 | "name": "inaccessible", 65 | "via": [example.graphql] 👉@link(url: "https://specs.apollo.dev/inaccessible/v0.1"), 66 | }, 67 | Object { 68 | "gref": GRef , 69 | "implicit": true, 70 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 71 | "name": "@inaccessible", 72 | "via": [example.graphql] 👉@link(url: "https://specs.apollo.dev/inaccessible/v0.1"), 73 | }, 74 | ] 75 | `); 76 | 77 | expect(schema.refs).toMatchInlineSnapshot(` 78 | Record [ 79 | <>[example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 80 | [example.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v1.0"), 81 | [example.graphql] 👉@link(url: "https://specs.apollo.dev/inaccessible/v0.1"), 82 | <#User>[example.graphql] 👉type User @inaccessible {, 83 | [example.graphql] type User 👉@inaccessible {, 84 | [example.graphql] id: 👉ID!, 85 | ] 86 | `); 87 | }); 88 | 89 | it("can be created from a doc", () => { 90 | const schema = Schema.from( 91 | parse( 92 | new Source( 93 | `extend schema 94 | @id(url: "https://my.org/mySchema") 95 | @link(url: "https://specs.apollo.dev/link/v1.0") 96 | @link(url: "https://specs.apollo.dev/id/v1.0") 97 | @link(url: "https://example.com/foo") 98 | @link(url: "https://specs.company.org/someSpec/v1.2", as: spec) 99 | `, 100 | "example.graphql" 101 | ) 102 | ) 103 | ); 104 | expect(schema.url).toBe(LinkUrl.from("https://my.org/mySchema")); 105 | expect(schema.scope.own("link")?.gref).toBe( 106 | GRef.schema("https://specs.apollo.dev/link/v1.0") 107 | ); 108 | expect(schema.scope.own("spec")?.gref).toBe( 109 | GRef.schema("https://specs.company.org/someSpec/v1.2") 110 | ); 111 | expect(schema.scope.own("@foo")?.gref).toBe( 112 | GRef.rootDirective("https://example.com/foo") 113 | ); 114 | expect(schema.locate(ref("@spec__dir"))).toBe( 115 | GRef.directive("dir", "https://specs.company.org/someSpec/v1.2") 116 | ); 117 | }); 118 | 119 | it("locates nodes", () => { 120 | const schema = Schema.from( 121 | parse(` 122 | extend schema 123 | @link(url: "https://specs.apollo.dev/federation/v2.0", 124 | import: "@requires @key @prov: @provides") 125 | `), 126 | base.scope 127 | ); 128 | 129 | // note: .toBe checks are intentional, equal grefs 130 | // are meant to be identical (the same object) via 131 | // caching. this allows them to be treated as 132 | // values (e.g. used as keys in maps) 133 | expect(schema.locate(ref("@requires"))).toBe( 134 | GRef.directive("requires", "https://specs.apollo.dev/federation/v2.0") 135 | ); 136 | expect(schema.locate(ref("@provides"))).toBe(GRef.directive("provides")); 137 | expect(schema.locate(ref("@federation"))).toBe( 138 | GRef.directive("", "https://specs.apollo.dev/federation/v2.0") 139 | ); 140 | expect(schema.locate(ref("@prov"))).toBe( 141 | GRef.directive("provides", "https://specs.apollo.dev/federation/v2.0") 142 | ); 143 | expect(schema.locate(ref("link__Schema"))).toBe( 144 | GRef.named("Schema", "https://specs.apollo.dev/link/v1.0") 145 | ); 146 | 147 | // all nodes have locations 148 | expect(schema.locate(ref("link__Schema"))).toBe( 149 | GRef.named("Schema", "https://specs.apollo.dev/link/v1.0") 150 | ); 151 | }); 152 | 153 | it("understands @id", () => { 154 | const schema = Schema.basic(gql`${"schema-with-id.graphql"} 155 | @id(url: "https://specs/me") 156 | @link(url: "https://specs.apollo.dev/federation/v2.0", 157 | import: "@requires @key @prov: @provides") 158 | directive @me repeatable on SCHEMA 159 | scalar Something @key 160 | `); 161 | expect(schema.url).toBe(LinkUrl.from("https://specs/me")); 162 | expect(schema.locate(ref("@id"))).toBe( 163 | GRef.rootDirective("https://specs.apollo.dev/id/v1.0") 164 | ); 165 | expect(schema.locate(ref("@requires"))).toBe( 166 | GRef.directive("requires", "https://specs.apollo.dev/federation/v2.0") 167 | ); 168 | expect(schema.locate(ref("SomeLocalType"))).toBe( 169 | GRef.named("SomeLocalType", "https://specs/me") 170 | ); 171 | expect(schema.locate(ref("@myDirective"))).toBe( 172 | GRef.directive("myDirective", "https://specs/me") 173 | ); 174 | expect(schema).toMatchInlineSnapshot(` 175 | Schema [ 176 | GRef => GRef (via [schema-with-id.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v2.0"), 177 | GRef => GRef (via [schema-with-id.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v2.0"), 178 | GRef => GRef (via [schema-with-id.graphql] 👉@link(url: "https://specs.apollo.dev/federation/v2.0"), 179 | [schema-with-id.graphql] 👉@id(url: "https://specs/me"), 180 | [schema-with-id.graphql] 👉directive @me repeatable on SCHEMA, 181 | [schema-with-id.graphql] 👉scalar Something @key, 182 | ] 183 | `); 184 | 185 | // a self-link is added when the url has a name 186 | expect(schema.scope.own("")?.gref).toBe(GRef.schema("https://specs/me")); 187 | 188 | // directive terms with the same name as the current schema 189 | // are mapped to the root directive. 190 | expect(schema.locate(ref("@me"))).toBe( 191 | GRef.rootDirective("https://specs/me") 192 | ); 193 | }); 194 | 195 | it("gets definitions for nodes", () => { 196 | const schema = Schema.basic(gql`${"my-schema.graphql"} 197 | @id(url: "https://specs/me") 198 | @link(url: "https://specs.apollo.dev/federation/v2.0", 199 | import: "@requires @key @provides (as @prov)") 200 | 201 | type User @key(fields: "id") { 202 | id: ID! 203 | } 204 | `); 205 | 206 | const user = schema.locate(ref("User")); 207 | expect(schema.definitions(user)).toMatchInlineSnapshot(` 208 | Array [ 209 | [my-schema.graphql] 👉type User @key(fields: "id") {, 210 | ] 211 | `); 212 | 213 | expect(schema.definitions(schema.locate(ref("@link")))).toEqual([]); 214 | const link = schema.locate(ref("@link")); 215 | expect(link).toBe(GRef.rootDirective("https://specs.apollo.dev/link/v1.0")); 216 | }); 217 | 218 | it("compiles", () => { 219 | const builtins = Schema.basic(gql`${"builtins"} 220 | @link(url: "https://specs.apollo.dev/federation/v1.0", import: "@key") 221 | `); 222 | const atlas = Atlas.fromSchemas( 223 | Schema.basic(gql`${"link.graphql"} 224 | @id(url: "https://specs.apollo.dev/link/v1.0") 225 | 226 | directive @link(url: Url!, as: Name, import: Imports) 227 | repeatable on SCHEMA 228 | scalar Url 229 | scalar Name 230 | scalar Imports 231 | `), 232 | Schema.basic(gql`${"fed.graphql"} 233 | @id(url: "https://specs.apollo.dev/federation/v1.0") 234 | 235 | directive @key(fields: FieldSet!) on OBJECT 236 | scalar FieldSet 237 | `) 238 | ); 239 | 240 | expect(atlas).toMatchInlineSnapshot(` 241 | Atlas [ 242 | [link.graphql] 👉@id(url: "https://specs.apollo.dev/link/v1.0"), 243 | [link.graphql] 👉directive @link(url: Url!, as: Name, import: Imports), 244 | [link.graphql] 👉scalar Url, 245 | [link.graphql] 👉scalar Name, 246 | [link.graphql] 👉scalar Imports, 247 | [fed.graphql] 👉@id(url: "https://specs.apollo.dev/federation/v1.0"), 248 | [fed.graphql] 👉directive @key(fields: FieldSet!) on OBJECT, 249 | [fed.graphql] 👉scalar FieldSet, 250 | ] 251 | `); 252 | 253 | const subgraph = Schema.from( 254 | gql` 255 | ${"subgraph"} 256 | type User @key(fields: "x y z") { 257 | id: ID! 258 | field: SomeUnresolvedType 259 | } 260 | `, 261 | builtins 262 | ); 263 | 264 | const result = getResult(() => subgraph.compile(atlas)); 265 | expect([...result.errors()].map((e: any) => [e, e.nodes])) 266 | .toMatchInlineSnapshot(` 267 | Array [ 268 | Array [ 269 | [NoDefinition: no definitions found for reference: #SomeUnresolvedType], 270 | Array [ 271 | <#SomeUnresolvedType>[subgraph] field: 👉SomeUnresolvedType, 272 | ], 273 | ], 274 | ] 275 | `); 276 | const compiled = result.unwrap(); 277 | 278 | expect([...compiled]).toMatchInlineSnapshot(` 279 | Array [ 280 | GRef <#@key> => GRef (via [+] @link(url: "https://specs.apollo.dev/federation/v1.0", import: ["@key"])), 281 | <>[+] extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v1.0", import: ["@key"]), 282 | <#User>[subgraph] 👉type User @key(fields: "x y z") {, 283 | [link.graphql] 👉directive @link(url: Url!, as: Name, import: Imports), 284 | [link.graphql] 👉scalar Url, 285 | [link.graphql] 👉scalar Name, 286 | [link.graphql] 👉scalar Imports, 287 | [fed.graphql] 👉directive @key(fields: FieldSet!) on OBJECT, 288 | [fed.graphql] 👉scalar FieldSet, 289 | ] 290 | `); 291 | 292 | expect(raw(print(compiled.document))).toMatchInlineSnapshot(` 293 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/federation/v1.0", import: ["@key"]) 294 | 295 | type User @key(fields: "x y z") { 296 | id: ID! 297 | field: SomeUnresolvedType 298 | } 299 | 300 | directive @link(url: link__Url!, as: link__Name, import: link__Imports) repeatable on SCHEMA 301 | 302 | scalar link__Url 303 | 304 | scalar link__Name 305 | 306 | scalar link__Imports 307 | 308 | directive @key(fields: federation__FieldSet!) on OBJECT 309 | 310 | scalar federation__FieldSet 311 | `); 312 | }); 313 | 314 | describe("compiles -", () => { 315 | const atlas = Schema.basic(gql`${"zoo.graphql"} 316 | @id(url: "https://example.dev/zoo") 317 | @link(url: "https://example.dev/aardvark", import: "@ (as @aardvark)") 318 | @link(url: "https://example.dev/animals", import: "@zebra") 319 | 320 | directive @aardvark on OBJECT 321 | directive @zebra on OBJECT 322 | directive @link repeatable on SCHEMA 323 | `); 324 | 325 | it("transitive @links", () => { 326 | expect(atlas).toMatchInlineSnapshot(` 327 | Schema [ 328 | GRef => GRef (via [zoo.graphql] 👉@link(url: "https://example.dev/aardvark", import: "@ (as @aardvark)")), 329 | GRef => GRef (via [zoo.graphql] 👉@link(url: "https://example.dev/animals", import: "@zebra")), 330 | [zoo.graphql] 👉@id(url: "https://example.dev/zoo"), 331 | [zoo.graphql] 👉directive @aardvark on OBJECT, 332 | [zoo.graphql] 👉directive @zebra on OBJECT, 333 | [zoo.graphql] 👉directive @link repeatable on SCHEMA, 334 | ] 335 | `); 336 | 337 | const schema = Schema.basic(gql`${"input.graphql"} 338 | @link(url: "https://example.dev/zoo", import: "@aardvark @zebra") 339 | `); 340 | 341 | const result = getResult(() => schema.compile(atlas)); 342 | expect([...result.errors()]).toEqual([]); 343 | const output = result.unwrap(); 344 | 345 | expect(output).toMatchInlineSnapshot(` 346 | Schema [ 347 | GRef <#@zebra> => GRef (via [+] @link(url: "https://example.dev/animals", import: ["@zebra"])), 348 | <>[+] extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://example.dev/aardvark") @link(url: "https://example.dev/animals", import: ["@zebra"]), 349 | [zoo.graphql] 👉directive @link repeatable on SCHEMA, 350 | [zoo.graphql] 👉directive @aardvark on OBJECT, 351 | [zoo.graphql] 👉directive @zebra on OBJECT, 352 | ] 353 | `); 354 | 355 | expect(output.scope).toMatchInlineSnapshot(` 356 | Scope [ 357 | Object { 358 | "gref": GRef , 359 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 360 | "name": "link", 361 | "via": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 362 | }, 363 | Object { 364 | "gref": GRef , 365 | "implicit": true, 366 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 367 | "name": "@link", 368 | "via": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 369 | }, 370 | Object { 371 | "gref": GRef , 372 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 373 | "name": "aardvark", 374 | "via": [+] @link(url: "https://example.dev/aardvark"), 375 | }, 376 | Object { 377 | "gref": GRef , 378 | "implicit": true, 379 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 380 | "name": "@aardvark", 381 | "via": [+] @link(url: "https://example.dev/aardvark"), 382 | }, 383 | Object { 384 | "gref": GRef , 385 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 386 | "name": "animals", 387 | "via": [+] @link(url: "https://example.dev/animals", import: ["@zebra"]), 388 | }, 389 | Object { 390 | "gref": GRef , 391 | "implicit": true, 392 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 393 | "name": "@animals", 394 | "via": [+] @link(url: "https://example.dev/animals", import: ["@zebra"]), 395 | }, 396 | Object { 397 | "gref": GRef , 398 | "linker": [+] @link(url: "https://specs.apollo.dev/link/v1.0"), 399 | "name": "@zebra", 400 | "via": [+] @link(url: "https://example.dev/animals", import: ["@zebra"]), 401 | }, 402 | ] 403 | `); 404 | 405 | expect(raw(output.print())).toMatchInlineSnapshot(` 406 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://example.dev/aardvark") @link(url: "https://example.dev/animals", import: ["@zebra"]) 407 | 408 | directive @link repeatable on SCHEMA 409 | 410 | directive @aardvark on OBJECT 411 | 412 | directive @zebra on OBJECT 413 | `); 414 | }); 415 | }); 416 | 417 | it("returns standardized versions", () => { 418 | const subgraph = Schema.basic(gql`${"subgraph"} 419 | @link(url: "https://specs.apollo.dev/federation/v2.0", 420 | import: """ 421 | @fkey: @key 422 | @frequires: @requires 423 | @fprovides: @provides 424 | @ftag: @tag 425 | """) 426 | 427 | type User @fkey(fields: "id") { 428 | id: ID! @ftag(name: "hi") @tag(name: "my tag") 429 | } 430 | 431 | directive @tag(name: string) on FIELD_DEFINITION 432 | `); 433 | 434 | expect( 435 | raw( 436 | subgraph.standardize("https://specs.apollo.dev/federation/v2.0").print() 437 | ) 438 | ).toMatchInlineSnapshot(` 439 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/id/v1.0") @link(url: "https://specs.apollo.dev/federation/v2.0") 440 | 441 | type User @federation__key(fields: "id") { 442 | id: ID! @federation__tag(name: "hi") @tag(name: "my tag") 443 | } 444 | 445 | directive @tag(name: string) on FIELD_DEFINITION 446 | `); 447 | }); 448 | 449 | it("omits links and namespacing for graphql builtins", () => { 450 | const tag = Schema.basic(gql`${"tag/v0.1"} 451 | @id(url: "https://specs.apollo.dev/tag/v0.1") 452 | directive @tag(name: String!) 453 | repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION 454 | `); 455 | expect( 456 | refNodesIn( 457 | tag.definitions(GRef.rootDirective("https://specs.apollo.dev/tag/v0.1")) 458 | ) 459 | ).toMatchInlineSnapshot(` 460 | Iterable [ 461 | [tag/v0.1] 👉directive @tag(name: String!), 462 | [tag/v0.1] directive @tag(name: 👉String!), 463 | ] 464 | `); 465 | 466 | const schema = Schema.basic(gql`${"user-schema"} 467 | @link(url: "https://specs.apollo.dev/tag/v0.1") 468 | extend type User @tag(name: "tagged") 469 | `); 470 | expect(raw(schema.compile(tag).print())).toMatchInlineSnapshot(` 471 | extend schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/tag/v0.1") 472 | 473 | extend type User @tag(name: "tagged") 474 | 475 | directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION 476 | `); 477 | }); 478 | 479 | it("handles @link import string with list of objects", () => { 480 | const schema = Schema.basic(gql`@link(url: "https://example", 481 | import: ["@foo", {name: "@bar", as: "@barAlias"}, {name: "Type", as: "TypeAlias"}])`); 482 | expect(schema.scope).toMatchInlineSnapshot(` 483 | Scope [ 484 | Object { 485 | "gref": GRef , 486 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 487 | "name": undefined, 488 | "via": [GraphQL request] 👉@link(url: "https://example", 489 | }, 490 | Object { 491 | "gref": GRef , 492 | "implicit": true, 493 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 494 | "name": "@undefined", 495 | "via": [GraphQL request] 👉@link(url: "https://example", 496 | }, 497 | Object { 498 | "gref": GRef , 499 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 500 | "name": "@foo", 501 | "via": [GraphQL request] 👉@link(url: "https://example", 502 | }, 503 | Object { 504 | "gref": GRef , 505 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 506 | "name": "@barAlias", 507 | "via": [GraphQL request] 👉@link(url: "https://example", 508 | }, 509 | Object { 510 | "gref": GRef , 511 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 512 | "name": "TypeAlias", 513 | "via": [GraphQL request] 👉@link(url: "https://example", 514 | }, 515 | ] 516 | `); 517 | }); 518 | 519 | it("handles @link import string with ':' aliases", () => { 520 | const schema = Schema.basic(gql`@link(url: "https://example", 521 | import: "@foo @barAlias: @bar TypeAlias: Type")`); 522 | expect(schema.scope).toMatchInlineSnapshot(` 523 | Scope [ 524 | Object { 525 | "gref": GRef , 526 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 527 | "name": undefined, 528 | "via": [GraphQL request] 👉@link(url: "https://example", 529 | }, 530 | Object { 531 | "gref": GRef , 532 | "implicit": true, 533 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 534 | "name": "@undefined", 535 | "via": [GraphQL request] 👉@link(url: "https://example", 536 | }, 537 | Object { 538 | "gref": GRef , 539 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 540 | "name": "@foo", 541 | "via": [GraphQL request] 👉@link(url: "https://example", 542 | }, 543 | Object { 544 | "gref": GRef , 545 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 546 | "name": "@barAlias", 547 | "via": [GraphQL request] 👉@link(url: "https://example", 548 | }, 549 | Object { 550 | "gref": GRef , 551 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 552 | "name": "TypeAlias", 553 | "via": [GraphQL request] 👉@link(url: "https://example", 554 | }, 555 | ] 556 | `); 557 | }); 558 | 559 | it("handles @link import string with (as) aliases", () => { 560 | const schema = Schema.basic(gql`@link(url: "https://example", 561 | import: "@foo @bar (as @barAlias) Type (as TypeAlias)")`); 562 | expect(schema.scope).toMatchInlineSnapshot(` 563 | Scope [ 564 | Object { 565 | "gref": GRef , 566 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 567 | "name": undefined, 568 | "via": [GraphQL request] 👉@link(url: "https://example", 569 | }, 570 | Object { 571 | "gref": GRef , 572 | "implicit": true, 573 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 574 | "name": "@undefined", 575 | "via": [GraphQL request] 👉@link(url: "https://example", 576 | }, 577 | Object { 578 | "gref": GRef , 579 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 580 | "name": "@foo", 581 | "via": [GraphQL request] 👉@link(url: "https://example", 582 | }, 583 | Object { 584 | "gref": GRef , 585 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 586 | "name": "@barAlias", 587 | "via": [GraphQL request] 👉@link(url: "https://example", 588 | }, 589 | Object { 590 | "gref": GRef , 591 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 592 | "name": "TypeAlias", 593 | "via": [GraphQL request] 👉@link(url: "https://example", 594 | }, 595 | ] 596 | `); 597 | }); 598 | 599 | it("does not get confused", () => { 600 | const schema = Schema.basic(gql` 601 | @link(url: "https://example/one") 602 | @one(url: "https://example/one") 603 | @one(url: "https://example/two") 604 | @two(urlxx: "https://zya") 605 | `); 606 | expect(schema.scope).toMatchInlineSnapshot(` 607 | Scope [ 608 | Object { 609 | "gref": GRef , 610 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 611 | "name": "one", 612 | "via": [GraphQL request] 👉@link(url: "https://example/one"), 613 | }, 614 | Object { 615 | "gref": GRef , 616 | "implicit": true, 617 | "linker": [builtin:schema/basic] 👉@link(url: "https://specs.apollo.dev/link/v1.0"), 618 | "name": "@one", 619 | "via": [GraphQL request] 👉@link(url: "https://example/one"), 620 | }, 621 | ] 622 | `); 623 | }); 624 | 625 | it("dangerously removes headers", () => { 626 | const schema = Schema.basic(gql` 627 | @link(url: "https://some-link/spec") 628 | @link(url: "https://another-link/otherSpec") 629 | 630 | type User @otherSpec { 631 | id: ID! 632 | field: Foo 633 | } 634 | `); 635 | expect(schema.dangerousRemoveHeaders().print()).toMatchInlineSnapshot(` 636 | "type User @otherSpec { 637 | id: ID! 638 | field: Foo 639 | }" 640 | `); 641 | }); 642 | }); 643 | 644 | function ref(name: string): Locatable { 645 | if (name.startsWith("@")) 646 | return { 647 | kind: Kind.DIRECTIVE, 648 | name: { kind: Kind.NAME, value: name.slice(1) }, 649 | }; 650 | return { 651 | kind: Kind.NAMED_TYPE, 652 | name: { kind: Kind.NAME, value: name }, 653 | }; 654 | } 655 | -------------------------------------------------------------------------------- /src/__tests__/scope-map.test.ts: -------------------------------------------------------------------------------- 1 | import ScopeMap from '../scope-map' 2 | 3 | describe("scope maps", () => { 4 | const scope = new ScopeMap() 5 | scope.set('hello', 'world') 6 | scope.set('goodbye', 'friend') 7 | 8 | it("stores entries", () => { 9 | expect([...scope.entries()]).toEqual([ 10 | ['hello', 'world'], 11 | ['goodbye', 'friend'], 12 | ]) 13 | expect(scope.lookup('hello')).toBe('world') 14 | expect(scope.lookup('goodbye')).toBe('friend') 15 | }) 16 | 17 | const child = new ScopeMap(scope) 18 | child.set('hello', 'child world') 19 | child.set('farewell', 'child') 20 | 21 | it("looks up entries heirarchically", () => { 22 | expect(child.lookup('hello')).toBe('child world') 23 | expect(child.lookup('farewell')).toBe('child') 24 | expect(child.lookup('goodbye')).toBe('friend') 25 | }) 26 | 27 | it('can examine the full lookup chain', () => { 28 | expect([...child.visible()]).toEqual([ 29 | ['hello', 'child world'], 30 | ['farewell', 'child'], 31 | ['goodbye', 'friend'], 32 | ]) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/__tests__/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { EnumValueDefinitionNode, Kind } from 'graphql' 2 | import Scope from '../scope' 3 | 4 | describe('a scope', () => { 5 | it('does not treat enum value definitions as references', () => { 6 | const node: EnumValueDefinitionNode = { 7 | kind: Kind.ENUM_VALUE_DEFINITION, 8 | name: { kind: Kind.NAME, value: 'HELLO' } 9 | } 10 | expect(Scope.EMPTY.denormalize(node)) 11 | .toBe(node) 12 | }) 13 | }) -------------------------------------------------------------------------------- /src/__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.test", 3 | "include": ["**/*"], 4 | "references": [ 5 | { "path": "../../" }, 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/__tests__/version.test.ts: -------------------------------------------------------------------------------- 1 | import { Version } from "../version"; 2 | 3 | describe("version", () => { 4 | describe(".parse", () => { 5 | it(".parse parses valid version tags", () => { 6 | expect(Version.parse("v1.0")).toEqual(Version.canon(1, 0)); 7 | expect(Version.parse("v0.1")).toEqual(Version.canon(0, 1)); 8 | expect(Version.parse("v987.65432")).toEqual(Version.canon(987, 65432)); 9 | }); 10 | 11 | it("returns null for invalid versions", () => { 12 | expect(Version.parse("bloop")).toBeNull() 13 | expect(Version.parse("v1")).toBeNull() 14 | expect(Version.parse("v1.")).toBeNull() 15 | expect(Version.parse("1.2")).toBeNull() 16 | expect(Version.parse("v0.9-tags-are-not-supported")).toBeNull() 17 | }); 18 | }); 19 | describe(".satisfies", () => { 20 | it("returns true if this version satisfies the requested version", () => { 21 | expect(Version.canon(1, 0).satisfies(Version.canon(1, 0))).toBe(true); 22 | expect(Version.canon(1, 2).satisfies(Version.canon(1, 0))).toBe(true); 23 | }); 24 | 25 | it("returns false if this version cannot satisfy the requested version", () => { 26 | expect(Version.canon(2, 0).satisfies(Version.canon(1, 9))).toBe(false); 27 | expect(Version.canon(0, 9).satisfies(Version.canon(0, 8))).toBe(false); 28 | }); 29 | }); 30 | it(".equals returns true iff the versions are exactly equal", () => { 31 | expect(Version.canon(2, 9).equals(Version.canon(2, 9))).toBe(true); 32 | expect(Version.canon(2, 9).equals(Version.canon(2, 8))).toBe(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/atlas.ts: -------------------------------------------------------------------------------- 1 | import recall, { use } from '@protoplasm/recall' 2 | import { Defs } from './de'; 3 | import GRef, { byGref } from './gref'; 4 | import Schema from './schema'; 5 | 6 | export class Atlas implements Defs { 7 | @use(recall) 8 | static fromSchemas(...schemas: Schema[]): Atlas { 9 | return new this(schemas) 10 | } 11 | 12 | *definitions(ref?: GRef): Defs { 13 | if (!ref) return this 14 | return yield* byGref(...this.schemas).get(ref) ?? [] 15 | } 16 | 17 | *[Symbol.iterator]() { 18 | for (const schema of this.schemas) 19 | yield* schema.definitions() 20 | } 21 | 22 | constructor(public readonly schemas: Schema[]) {} 23 | } 24 | -------------------------------------------------------------------------------- /src/de.ts: -------------------------------------------------------------------------------- 1 | import { replay, report } from '@protoplasm/recall' 2 | import { ASTNode, DefinitionNode, DirectiveNode, Kind, NamedTypeNode } from 'graphql' 3 | import { first } from './each' 4 | import err from './error' 5 | import GRef, { byGref, HasGref } from './gref' 6 | import { isAst } from './is' 7 | import LinkUrl from './link-url' 8 | 9 | /** 10 | * A reference could not be matched to a definition. 11 | * 12 | * @param gref 13 | * @param nodes 14 | * @returns ErrNoDefinition 15 | */ 16 | export const ErrNoDefinition = (gref: GRef, ...nodes: ASTNode[]) => 17 | err('NoDefinition', { 18 | message: `no definitions found for reference: ${gref}`, 19 | gref, 20 | nodes 21 | }) 22 | 23 | /** 24 | * A detatched (or denormalized) AST node. Detached nodes have an `gref' 25 | * property which holds their location within the global graph. This makes them 26 | * easier to move them between documents, which may have different sets of `@link` 27 | * directives (and thus different namespaces). 28 | */ 29 | export type De = 30 | T extends (infer E)[] 31 | ? De[] 32 | : 33 | T extends Locatable 34 | ? { 35 | [K in keyof T]: 36 | K extends 'kind' | 'loc' 37 | ? T[K] 38 | : 39 | De 40 | } & HasGref 41 | : 42 | T extends object 43 | ? { 44 | [K in keyof T]: K extends 'kind' | 'loc' 45 | ? T[K] 46 | : 47 | De 48 | } 49 | : 50 | T 51 | 52 | export type Def = De | Redirect 53 | export type Defs = Iterable 54 | 55 | export interface Redirect { 56 | code: 'Redirect' 57 | gref: GRef 58 | toGref: GRef 59 | via: DirectiveNode 60 | } 61 | 62 | export const isRedirect = (o: any): o is Redirect => o?.code === 'Redirect' 63 | 64 | export type Locatable = 65 | | DefinitionNode 66 | | DirectiveNode 67 | | NamedTypeNode 68 | 69 | export type Located = Locatable & HasGref 70 | 71 | 72 | /** 73 | * Complete `source` definitions with definitions from `atlas`. 74 | * 75 | * Emits the set of defs to be added along with *all* Redirects which were 76 | * followed to find them. Callers should use the redirects to update 77 | * redirected references to their final location. 78 | * 79 | * Reports ErrNoDefinition for any dangling references. 80 | * 81 | * @param source the source defs which need filling in 82 | * @param atlas all the defs we could fill 83 | * @yields denormalized definition nodes and redirects 84 | */ 85 | export function *fill(source: Defs, atlas?: Defs): Defs { 86 | const notDefined = new Map() 87 | const seen = new Set(byGref(onlyDefinitions(source)).keys()) 88 | const atlasDefs = atlas ? byGref(atlas) : null 89 | 90 | ingest(source) 91 | 92 | while (notDefined.size) { 93 | const [ref, nodes] = first(notDefined.entries()) 94 | notDefined.delete(ref) 95 | if (seen.has(ref)) continue 96 | seen.add(ref) 97 | const defs = atlasDefs?.get(ref) 98 | if (!defs) { 99 | report(ErrNoDefinition(ref, ...nodes)) 100 | continue 101 | } 102 | ingest(defs) 103 | yield* defs 104 | } 105 | 106 | function ingest(defs: Defs) { 107 | for (const node of refNodesIn(defs)) 108 | if (isRedirect(node)) 109 | addGref(node.toGref, node.via) 110 | else 111 | addGref(node.gref, node) 112 | } 113 | 114 | function addGref(gref: GRef, node: Locatable) { 115 | if (seen.has(gref) || gref.graph === LinkUrl.GRAPHQL_SPEC) 116 | return 117 | const existing = notDefined.get(gref) 118 | if (existing) 119 | existing.push(node) 120 | else 121 | notDefined.set(gref, [node]) 122 | } 123 | } 124 | 125 | function *onlyDefinitions(defs: Defs): Iterable> { 126 | for (const def of defs) if (!isRedirect(def)) yield def 127 | } 128 | 129 | export function *refNodesIn(defs: Defs | Iterable): Iterable { 130 | for (const def of defs) { 131 | if (isRedirect(def)) yield def 132 | else yield* deepRefs(def) 133 | } 134 | } 135 | 136 | export const deepRefs: (root: ASTNode | ASTNode[]) => Iterable = replay( 137 | function *(root: ASTNode | Iterable) { 138 | if (isLocatable(root) && hasRef(root)) yield root 139 | for (const child of children(root)) { 140 | if (isAst(child)) yield *deepRefs(child) 141 | } 142 | } 143 | ) 144 | 145 | type ChildOf = 146 | T extends (infer E)[] 147 | ? E 148 | : 149 | T extends object 150 | ? { 151 | [k in keyof T]: T[k] extends (infer E)[] 152 | ? E 153 | : T[k] 154 | }[keyof T] 155 | : 156 | T 157 | 158 | export function *children(root: T): Iterable> { 159 | if (Array.isArray(root)) return yield *root 160 | if (typeof root === 'object') { 161 | for (const child of Object.values(root)) { 162 | if (Array.isArray(child)) yield *child 163 | else yield child 164 | } 165 | } 166 | } 167 | 168 | export const hasRef = (o?: any): o is HasGref => 169 | o?.gref instanceof GRef 170 | 171 | const LOCATABLE_KINDS = new Set([ 172 | ...Object.values(Kind) 173 | .filter(k => k.endsWith('Definition') || k.endsWith('Extension')) 174 | .filter(k => !k.startsWith('Field')) 175 | .filter(k => k !== 'OperationDefinition' && k !== 'FragmentDefinition'), 176 | Kind.DIRECTIVE, 177 | Kind.NAMED_TYPE, 178 | ]) 179 | 180 | export function isLocatable(o: any): o is Locatable { 181 | return LOCATABLE_KINDS.has(o?.kind) 182 | } 183 | 184 | export function isLocated(o: any): o is Located { 185 | return isLocatable(o) && hasRef(o) 186 | } 187 | -------------------------------------------------------------------------------- /src/directives.ts: -------------------------------------------------------------------------------- 1 | import { replay } from '@protoplasm/recall' 2 | import { ASTNode, DefinitionNode, DirectiveNode, DocumentNode, Kind, SchemaDefinitionNode, SchemaExtensionNode } from 'graphql' 3 | import { isAst } from './is' 4 | 5 | export type HasDirectives = DocumentNode | ASTNode & { directives?: DirectiveNode[] } 6 | 7 | export const schemaDefinitions = replay( 8 | function *nodes(defs: Iterable): Iterator { 9 | for (const def of defs) { 10 | if (isAst(def, Kind.SCHEMA_DEFINITION, Kind.SCHEMA_EXTENSION)) yield def 11 | } 12 | } 13 | ) 14 | 15 | export const directives = replay( 16 | function *directives(target: HasDirectives) { 17 | if (isAst(target, Kind.DOCUMENT)) { 18 | for (const def of schemaDefinitions(target.definitions)) { 19 | if (!def.directives) continue 20 | yield *def.directives 21 | } 22 | return 23 | } 24 | if (target.directives) yield *target.directives 25 | } 26 | ) 27 | 28 | export default directives -------------------------------------------------------------------------------- /src/each.ts: -------------------------------------------------------------------------------- 1 | import recall, { Recall, replay } from "@protoplasm/recall" 2 | import err from "./error" 3 | 4 | type ItemType any> = Parameters[0] 5 | type ElementType> = I extends Iterable ? T : never 6 | 7 | export const ErrEmpty = (iterable?: Iterable) => 8 | err('Empty', { 9 | message: 'expected at least one value, found zero', 10 | iterable 11 | }) 12 | 13 | export const ErrTooMany = (iterable: Iterable) => 14 | err('TooMany', { 15 | message: 'expected at most one value, found more', 16 | iterable 17 | }) 18 | 19 | export function first>(iter?: I): ElementType { 20 | if (!iter) throw ErrEmpty(iter) 21 | const it = iter[Symbol.iterator]() 22 | const r = it.next() 23 | if (r.done) throw ErrEmpty(iter) 24 | return r.value 25 | } 26 | 27 | export function only>(iter?: I): ElementType { 28 | if (!iter) throw ErrEmpty(iter) 29 | const it = iter[Symbol.iterator]() 30 | const r = it.next() 31 | if (r.done) throw ErrEmpty(iter) 32 | try { 33 | return r.value 34 | } finally { 35 | if (!it.next().done) 36 | throw ErrTooMany(iter) 37 | } 38 | } 39 | 40 | export function maybe>(iter?: I): ElementType | undefined { 41 | if (!iter) return undefined 42 | const it = iter[Symbol.iterator]() 43 | const r = it.next() 44 | return r.value 45 | } 46 | 47 | export function maybeOne>(iter?: I): ElementType | undefined { 48 | if (!iter) return 49 | const it = iter[Symbol.iterator]() 50 | const r = it.next() 51 | if (r.done) return 52 | try { 53 | return r.value 54 | } finally { 55 | if (!it.next().done) 56 | throw ErrTooMany(iter) 57 | } 58 | } 59 | 60 | export const groupBy: Recall< any>(grouper: G) => >(...sources: Iterable[]) => Readonly, Iterable>>> = recall ( 61 | any>(grouper: G): >(...sources: Iterable[]) => Readonly, Iterable>> => { 62 | const groupSources = recall( 63 | >(...sources: Iterable[]): Readonly, Iterable>> => { 64 | if (sources.length === 0) return Object.freeze(new Map) 65 | 66 | type Key = ReturnType 67 | 68 | if (sources.length > 1) { 69 | const defs = new Map() 70 | for (const src of sources) for (const ent of groupSources(src)) 71 | defs.set(ent[0], 72 | Object.freeze((defs.get(ent[0]) ?? []).concat(ent[1] as T[]))) 73 | return Object.freeze(defs) 74 | } 75 | 76 | const [source] = sources 77 | const defs = new Map() 78 | for (const def of source) { 79 | const key = grouper(def) 80 | const existing = defs.get(key) 81 | if (existing) existing.push(def) 82 | else defs.set(key, [def]) 83 | } 84 | for (const ary of defs.values()) { Object.freeze(ary) } 85 | return Object.freeze(defs) 86 | }) 87 | return groupSources 88 | } 89 | ) 90 | 91 | export const flat = replay( 92 | function *flat>>(iters: I): Iterator>> { 93 | for (const iter of iters) 94 | yield *iter 95 | } 96 | ) 97 | 98 | export const concat = (...iters: Iterable[]): Iterable => 99 | flat(iters) 100 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLError, Source } from 'graphql' 2 | import { Maybe } from 'graphql/jsutils/Maybe' 3 | 4 | export type Props = { 5 | message: string; 6 | nodes?: Maybe | ASTNode>; 7 | source?: Maybe; 8 | positions?: Maybe>; 9 | path?: Maybe>; 10 | originalError?: Maybe; 11 | extensions?: Maybe<{ [key: string]: any }>; 12 | causes?: Error[]; 13 | }; 14 | 15 | export class GraphQLErrorExt extends GraphQLError { 16 | static readonly BASE_PROPS = new Set( 17 | "nodes source positions path originalError extensions".split(" ") 18 | ); 19 | 20 | readonly name: string; 21 | 22 | constructor(public readonly code: C, message: string, props?: Props) { 23 | super(message, props) 24 | if (props) for (const prop in props) 25 | if (!GraphQLErrorExt.BASE_PROPS.has(prop)) { 26 | (this as any)[prop] = (props as any)[prop] 27 | } 28 | 29 | this.name = code 30 | this.extensions.code = code 31 | } 32 | 33 | throw(): never { throw this } 34 | toString() { 35 | let output = `[${this.code}] ${super.toString()}` 36 | const causes = (this as any).causes 37 | if (causes && causes.length) { 38 | output += "\ncaused by:"; 39 | for (const cause of (this as any).causes || []) { 40 | if (!cause) continue; 41 | output += "\n\n - "; 42 | output += cause.toString().split("\n").join("\n "); 43 | } 44 | } 45 | 46 | return output; 47 | } 48 | } 49 | 50 | /** 51 | * Return a GraphQLError with a code and arbitrary set of properties. 52 | * 53 | * This mainly helps deal with the very long list of parameters that GraphQLError's constructor 54 | * can take. It also ensures that all errors have a code, and provides a return typing that 55 | * facilitates extracting the provided props based on the code, as TypeScript will consider a union of 56 | * these errors to be a tagged union. 57 | * 58 | * @param code 59 | * @param props 60 | * @returns 61 | */ 62 | export function err( 63 | code: C, 64 | props: P | string 65 | ): GraphQLErrorExt & P { 66 | const message = typeof props === "string" ? props : props.message; 67 | const error = new GraphQLErrorExt( 68 | code, 69 | message, 70 | typeof props === "string" ? undefined : props 71 | ); 72 | return error as any; 73 | } 74 | 75 | export default err 76 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | // autogenerated by ../generate-errors.js 2 | // regenerate when new error types are added anywhere in the project. 3 | // to regenerate: npm run build && node ./generate-errors 4 | 5 | import { ErrEmpty, ErrTooMany } from "./each"; 6 | import { ErrExtraImport } from "./scope"; 7 | import { ErrNoDefinition } from "./de"; 8 | import { ErrBadImport } from "./linker"; 9 | 10 | export type AnyError = ReturnType< 11 | | typeof ErrEmpty 12 | | typeof ErrTooMany 13 | | typeof ErrExtraImport 14 | | typeof ErrNoDefinition 15 | | typeof ErrBadImport 16 | >; 17 | 18 | const ERROR_CODES = new Set([ 19 | "Empty", 20 | "TooMany", 21 | "ExtraImport", 22 | "NoDefinition", 23 | "BadImport", 24 | ]); 25 | 26 | export function isAnyError(o: any): o is AnyError { 27 | return ERROR_CODES.has(o?.code); 28 | } 29 | -------------------------------------------------------------------------------- /src/gql.ts: -------------------------------------------------------------------------------- 1 | import { ParseOptions, Source, DefinitionNode, Kind, TokenKind } from 'graphql' 2 | import { Parser as BaseParser } from 'graphql/language/parser' 3 | 4 | export interface Options extends ParseOptions { 5 | name?: string 6 | } 7 | 8 | export function gql(body: TemplateStringsArray, opts?: string | Options) { 9 | const name = typeof opts === 'string' ? opts : opts?.name 10 | const source = new Source(String.raw(body), name) 11 | const parser = new Parser(source, typeof opts === 'object' ? opts : {}) 12 | return parser.parseDocument() 13 | } 14 | 15 | export default gql 16 | 17 | export class Parser extends BaseParser { 18 | parseDefinition(): DefinitionNode { 19 | if (this.peek(TokenKind.AT)) { 20 | return this.node(this._lexer.token, { 21 | kind: Kind.SCHEMA_EXTENSION, 22 | directives: this.parseDirectives(true) 23 | }) 24 | } 25 | return super.parseDefinition() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/gref.ts: -------------------------------------------------------------------------------- 1 | import recall, { use } from '@protoplasm/recall' 2 | import { groupBy } from './each' 3 | import LinkUrl from './link-url' 4 | 5 | export class GRef { 6 | @use(recall) 7 | static canon(name: string, graph?: LinkUrl): GRef { 8 | return new this(name, graph) 9 | } 10 | 11 | static named(name: string, graph?: LinkUrl | string) { 12 | return this.canon(name, LinkUrl.from(graph)) 13 | } 14 | 15 | static directive(name: string, graph?: LinkUrl | string) { 16 | return this.canon('@' + name, LinkUrl.from(graph)) 17 | } 18 | 19 | static rootDirective(graph?: LinkUrl | string) { 20 | return this.directive('', graph) 21 | } 22 | 23 | static schema(graph?: LinkUrl | string) { 24 | return this.canon('', LinkUrl.from(graph)) 25 | } 26 | 27 | setGraph(graph?: LinkUrl | string) { 28 | return GRef.canon(this.name, LinkUrl.from(graph)) 29 | } 30 | 31 | setName(name: string) { 32 | return GRef.canon(name, this.graph) 33 | } 34 | 35 | toString() { 36 | const graph = this.graph?.href ?? '' 37 | return graph + (this.name ? `#${this.name}` : '') 38 | } 39 | 40 | isSchema() { return this.name === '' } 41 | 42 | private constructor(public readonly name: string, public readonly graph?: LinkUrl) {} 43 | } 44 | 45 | export default GRef 46 | 47 | export interface HasGref { 48 | gref: GRef 49 | } 50 | 51 | /** 52 | * group detached nodes (or anything with an 'hgref' really ) 53 | */ 54 | export const byGref = groupBy((node: any): GRef => node?.gref) 55 | -------------------------------------------------------------------------------- /src/import.ts: -------------------------------------------------------------------------------- 1 | import { ConstDirectiveNode, DirectiveNode, Kind, Location, NamedTypeNode, NameNode, Source, TokenKind } from 'graphql' 2 | import { Parser } from 'graphql/language/parser' 3 | 4 | export type ImportTermNode = DirectiveNode | NamedTypeNode 5 | export interface ImportNode { 6 | type: 'Import' 7 | element: T 8 | alias?: T 9 | loc?: Location 10 | } 11 | 12 | export class ImportsParser extends Parser { 13 | static fromString(source: string) { 14 | return new ImportsParser(new Source(source)) 15 | .parseImports() 16 | } 17 | 18 | /** 19 | * Imports: Import+ 20 | * 21 | * @returns import nodes 22 | */ 23 | parseImports(): ImportNode[] { 24 | return this.many( 25 | TokenKind.SOF, 26 | this.parseImport, 27 | TokenKind.EOF, 28 | ) 29 | } 30 | 31 | /** 32 | * Import: 33 | * ImportName 34 | * ImportDirective 35 | * 36 | * ImportName: Alias? Name 37 | * Alias: Name ":" 38 | * ImportDirective: DirectiveAlias? DirectiveName 39 | * DirectiveName: "@" Name 40 | * DirectiveAlias: DirectiveName ":" 41 | * 42 | * @returns the import node 43 | */ 44 | parseImport() { 45 | const start = this._lexer.token 46 | const first = this.parseImportElement() 47 | if (this.peek(TokenKind.COLON)) { 48 | this.expectToken(TokenKind.COLON) 49 | const remote = this.parseImportElement() 50 | if (remote.kind !== first.kind) 51 | throw new Error('local and remote name must be same kind of reference') 52 | return this.node(start, { 53 | type: 'Import', 54 | element: remote, 55 | alias: first 56 | }) 57 | } 58 | if (this.peek(TokenKind.PAREN_L)) { 59 | this.expectToken(TokenKind.PAREN_L) 60 | this.expectKeyword('as') 61 | const local = this.parseImportElement() 62 | if (local.kind !== first.kind) 63 | throw new Error('local and remote name must be same kind of reference') 64 | this.expectToken(TokenKind.PAREN_R) 65 | return this.node(start, { 66 | type: 'Import', 67 | element: first, 68 | alias: local 69 | }) 70 | } 71 | return this.node(start, { 72 | type: 'Import', 73 | element: first 74 | }) 75 | } 76 | 77 | parseImportElement() { 78 | if (this.peek(TokenKind.AT)) 79 | return this.parseDirectiveName() 80 | return this.parseNamedType() 81 | } 82 | 83 | parseDirectiveName() { 84 | const start = this._lexer.token 85 | const at = this.expectToken(TokenKind.AT) 86 | if (this.peek(TokenKind.NAME)) { 87 | // accept immediately adjacent names only 88 | const tok = this._lexer.token 89 | if (tok.line === at.line && tok.column === at.column + 1) 90 | return this.node(start, { 91 | kind: Kind.DIRECTIVE, 92 | name: this.parseName() 93 | }) 94 | } 95 | return this.node(start, { 96 | kind: Kind.DIRECTIVE, 97 | name: this.node(at, { kind: Kind.NAME, value: '' }) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {Schema as default} from './schema' 2 | export {Schema} from './schema' 3 | export {Atlas} from './atlas' 4 | 5 | export {LinkUrl} from './link-url' 6 | export {Version} from './version' 7 | export {GRef, byGref} from './gref' 8 | 9 | export {schemaDefinitions, directives} from './directives' 10 | 11 | export {groupBy, only, maybeOne, maybe, first, flat} from './each' 12 | export {isAst, hasName, byName, byKind, toDefinitionKind} from './is' 13 | export {gql} from './gql' 14 | export {getResult, report} from '@protoplasm/recall' 15 | 16 | export {err} from './error' 17 | export * from './errors' -------------------------------------------------------------------------------- /src/is.ts: -------------------------------------------------------------------------------- 1 | import { type ASTKindToNode, type ASTNode, Kind, type NameNode } from 'graphql' 2 | import { groupBy } from './each' 3 | 4 | export function isAst(obj: any, ...kinds: K[]): obj is ASTKindToNode[K] { 5 | if (!kinds.length) 6 | return typeof obj?.kind === 'string' 7 | return kinds.indexOf(obj?.kind) !== -1 8 | } 9 | 10 | export type ToDefinitionKind = 11 | T extends `${infer _}Definition` 12 | ? T 13 | : 14 | T extends `${infer K}Extension` ? 15 | `${K}Definition` 16 | : 17 | undefined 18 | 19 | export function toDefinitionKind(kind: K): ToDefinitionKind { 20 | if (kind.endsWith('Definition')) return kind as ToDefinitionKind 21 | if (kind.endsWith('Extension')) 22 | return kind.substring(0, kind.length - 'Extension'.length) + 'Definition' as ToDefinitionKind 23 | return undefined as ToDefinitionKind 24 | } 25 | 26 | export const hasName = (o: T): o is T & { name: NameNode } => 27 | o && isAst((o as any).name, Kind.NAME) 28 | 29 | export const byName = groupBy( 30 | (field: T): T extends { name: NameNode } ? string : undefined => 31 | (field as any).name?.value 32 | ) 33 | 34 | export const byKind = groupBy( 35 | (node: T): T extends { kind: infer K } ? K : undefined => 36 | (node as any).kind 37 | ) 38 | -------------------------------------------------------------------------------- /src/link-url.ts: -------------------------------------------------------------------------------- 1 | import recall, { use } from '@protoplasm/recall' 2 | import { URL } from 'url' 3 | import Version from './version' 4 | export class LinkUrl { 5 | static from(input?: string | LinkUrl | null | undefined): LinkUrl | undefined 6 | static from(input: string | LinkUrl): LinkUrl 7 | static from(input?: string | LinkUrl) { 8 | if (typeof input === 'string') return this.parse(input) 9 | return input ?? undefined 10 | } 11 | 12 | static parse(input: string) { 13 | const url = new URL(input) 14 | const path = url.pathname ?? '' 15 | const parts = rsplit(path, '/') 16 | 17 | // the last two path components are (name)/(name or version) 18 | const nameVerPart = parts.next().value ?? undefined 19 | const namePart = parts.next().value ?? undefined 20 | 21 | const version = Version.parse(nameVerPart) 22 | const name = version ? parseName(namePart) : parseName(nameVerPart) 23 | 24 | // clear out unused url components 25 | url.search = '' 26 | url.password = '' 27 | url.username = '' 28 | url.hash = '' 29 | return this.canon(url.href, name ?? undefined, version ?? undefined) 30 | } 31 | 32 | static get GRAPHQL_SPEC() { 33 | return LinkUrl.from('https://specs.graphql.org') 34 | } 35 | 36 | *suggestNames(): Iterable { 37 | if (this.name) yield this.name 38 | if (this.name && this.version) { 39 | yield this.name + '_' + this.version.major 40 | yield this.name + '_' + this.version.major + '_' + this.version.minor 41 | } 42 | const url = new URL(this.href) 43 | if (url.hostname) { 44 | const parts = url.hostname.split('.') 45 | const [longest] = [...parts].sort((a, b) => b.length - a.length) 46 | if (this.name) { 47 | yield longest + '_' + this.name 48 | yield parts.join('_') + '_' + this.name 49 | } else { 50 | yield longest 51 | yield parts.join('_') 52 | } 53 | } 54 | const baseName = this.name || 'linked_schema' 55 | for (let i = 1;;++i) { 56 | yield baseName + '_' + i 57 | } 58 | } 59 | 60 | @use(recall) 61 | private static canon(href: string, name?: string, version?: Version): LinkUrl { 62 | return new this(href, name, version) 63 | } 64 | 65 | toString() { return this.href } 66 | 67 | private constructor( 68 | public readonly href: string, 69 | public readonly name?: string, 70 | public readonly version?: Version) {} 71 | } 72 | 73 | export default LinkUrl 74 | 75 | function *rsplit(haystack: string, sep: string) { 76 | let index = haystack.lastIndexOf(sep) 77 | const len = haystack.length 78 | const sepLen = sep.length 79 | let lastIndex = len 80 | while (index !== -1 && lastIndex > 0) { 81 | yield haystack.substring(index + sepLen, lastIndex) 82 | lastIndex = index 83 | index = haystack.lastIndexOf(sep, index - 1) 84 | } 85 | yield haystack.substring(0, lastIndex) 86 | } 87 | 88 | const NAME_RE = /^[a-zA-Z0-9\-]+$/ 89 | function parseName(name: string | null | void): string | null { 90 | if (!name) return null 91 | if (!NAME_RE.test(name)) return null 92 | if (name.startsWith('-') || name.endsWith('-')) return null 93 | return name 94 | } 95 | -------------------------------------------------------------------------------- /src/linker.ts: -------------------------------------------------------------------------------- 1 | import recall, { replay, report, use } from '@protoplasm/recall' 2 | import { GraphQLDirective, DirectiveNode, DirectiveLocation, GraphQLScalarType, GraphQLNonNull, Kind, ConstDirectiveNode, ConstArgumentNode, ValueNode, ASTNode } from 'graphql' 3 | import { getArgumentValues } from 'graphql/execution/values' 4 | import { Maybe } from 'graphql/jsutils/Maybe' 5 | import { ImportNode, ImportsParser } from './import' 6 | import type { IScope } from './scope' 7 | import {LinkUrl} from './link-url' 8 | import { GRef, HasGref } from './gref' 9 | import { scopeNameFor } from './names' 10 | import { groupBy, maybeOne, only } from './each' 11 | import { De } from './de' 12 | import { byName, isAst } from './is' 13 | import err from './error' 14 | import gql from './gql' 15 | import directives from './directives' 16 | 17 | const LINK_SPECS = new Map([ 18 | ['https://specs.apollo.dev/core/v0.1', 'feature'], 19 | ['https://specs.apollo.dev/core/v0.2', 'feature'], 20 | ['https://specs.apollo.dev/link/v0.3', 'url'], 21 | ['https://specs.apollo.dev/link/v1.0', 'url'], 22 | ]) 23 | 24 | export const LINK_DIRECTIVES = new Set( 25 | [...LINK_SPECS.keys()].map(url => GRef.rootDirective(url)) 26 | ) 27 | 28 | export const LINK_SPEC_URLS = new Set( 29 | [...LINK_DIRECTIVES].map(ref => ref.graph) 30 | ) 31 | 32 | const Url = new GraphQLScalarType({ 33 | name: 'Url', 34 | parseValue: val => val, 35 | parseLiteral(node): Maybe { 36 | if (node.kind === 'StringValue') 37 | return LinkUrl.parse(node.value) 38 | return null 39 | } 40 | }) 41 | 42 | const Name = new GraphQLScalarType({ 43 | name: 'Name', 44 | parseValue: val => val, 45 | parseLiteral(node): Maybe { 46 | if (node.kind === 'StringValue') return node.value 47 | if (node.kind === 'EnumValue') return node.value 48 | return 49 | } 50 | }) 51 | 52 | export const ErrBadImport = (node: ASTNode, expectedKinds: ASTNode["kind"][]) => 53 | err('BadImport', { 54 | message: `expected node of kind ${expectedKinds.join(' | ')}, got ${node.kind}`, 55 | node, expectedKinds 56 | }) 57 | 58 | const Imports = new GraphQLScalarType({ 59 | name: 'Imports', 60 | parseValue: val => val, 61 | parseLiteral(value: ValueNode): Maybe { 62 | if (value.kind === Kind.LIST) { 63 | const text = value.values.map(value => { 64 | if (value.kind === Kind.STRING) 65 | return value.value 66 | if (value.kind === Kind.OBJECT) { 67 | const name = only(byName(value.fields).get('name')).value 68 | const alias = maybeOne(byName(value.fields).get('as'))?.value 69 | if (!isAst(name, Kind.STRING, Kind.ENUM)) { 70 | report(ErrBadImport(name, [Kind.STRING, Kind.ENUM])) 71 | return 72 | } 73 | if (alias && !isAst(alias, Kind.STRING, Kind.ENUM)) { 74 | report(ErrBadImport(alias, [Kind.STRING, Kind.ENUM])) 75 | return 76 | } 77 | if (alias && alias.value !== name.value) 78 | return `${alias.value} : ${name.value}` 79 | return name.value 80 | } 81 | return undefined 82 | }).filter(Boolean).join(' ') 83 | return ImportsParser.fromString(text) 84 | } 85 | if (value.kind !== Kind.STRING) return 86 | return ImportsParser.fromString(value.value) 87 | } 88 | }) 89 | 90 | const $bootstrap = new GraphQLDirective({ 91 | name: 'link', 92 | args: { 93 | url: { type: Url }, 94 | feature: { type: Url }, 95 | as: { type: Name }, 96 | }, 97 | locations: [ DirectiveLocation.SCHEMA ], 98 | isRepeatable: true, 99 | }) 100 | 101 | export interface Link extends HasGref { 102 | name: string 103 | via?: DirectiveNode 104 | linker?: DirectiveNode 105 | implicit?: boolean 106 | } 107 | 108 | const $id = new GraphQLDirective({ 109 | name: 'id', 110 | args: { 111 | url: { type: new GraphQLNonNull(Url) }, 112 | as: { type: Name }, 113 | }, 114 | locations: [DirectiveLocation.SCHEMA], 115 | isRepeatable: true, 116 | }) 117 | 118 | const ID_DIRECTIVE = GRef.rootDirective('https://specs.apollo.dev/id/v1.0') 119 | 120 | export const id = recall( 121 | function id(scope: IScope, dir: DirectiveNode): Maybe { 122 | if (scope.locate(dir) === ID_DIRECTIVE) { 123 | const args = getArgumentValues($id, dir) 124 | const url = args.url as LinkUrl 125 | const name: string = (args.as ?? url.name) as string 126 | return { 127 | name, 128 | gref: GRef.schema(url), 129 | via: dir, 130 | } 131 | } 132 | return null 133 | } 134 | ) 135 | 136 | export class Linker { 137 | static from(scope: IScope, dir: DirectiveNode): Linker | undefined { 138 | const self = this.bootstrap(dir) 139 | if (self) return self 140 | const other = scope.lookup('@' + dir.name.value) 141 | if (!other?.via) return 142 | return Linker.bootstrap(other.via) 143 | } 144 | 145 | @use(recall) 146 | static bootstrap(strap: DirectiveNode): Linker | undefined { 147 | const args = getArgumentValues($bootstrap, strap) 148 | const url: Maybe = (args.url ?? args.feature) as LinkUrl 149 | if (!url) return 150 | const urlArg = LINK_SPECS.get(url.href) 151 | if (!urlArg) return 152 | if (args[urlArg] !== url) return 153 | return new this(strap, url, urlArg) 154 | } 155 | 156 | static readonly DEFAULT = this.bootstrap(only(directives(gql 157 | `@link(url: "https://specs.apollo.dev/link/v1.0")`)))! 158 | 159 | protected constructor(public readonly strap: DirectiveNode, 160 | public readonly url: LinkUrl, 161 | private readonly urlParam: string) {} 162 | 163 | #link = new GraphQLDirective({ 164 | name: this.strap.name.value, 165 | args: { 166 | [this.urlParam]: { type: new GraphQLNonNull(Url) }, 167 | as: { type: Name }, 168 | import: { type: Imports }, 169 | }, 170 | locations: [DirectiveLocation.SCHEMA], 171 | isRepeatable: true, 172 | }) 173 | 174 | @use(replay) 175 | *links(directive: DirectiveNode): Iterable { 176 | const args = getArgumentValues(this.#link, directive) 177 | const url = args[this.urlParam] as LinkUrl 178 | const name: string = (args.as ?? url.name) as string 179 | if (name !== '') { 180 | yield { 181 | name, 182 | gref: GRef.schema(url), 183 | via: directive, 184 | linker: this.strap, 185 | } 186 | yield { 187 | name: '@' + name, 188 | gref: GRef.rootDirective(url), 189 | via: directive, 190 | linker: this.strap, 191 | implicit: true, 192 | } 193 | } 194 | for (const i of args.import as ImportNode[] ?? []) { 195 | const alias = scopeNameFor(i.alias ?? i.element) 196 | const name = scopeNameFor(i.element) 197 | yield { 198 | name: alias, 199 | gref: GRef.named(name, url), 200 | via: directive, 201 | linker: this.strap, 202 | } 203 | } 204 | } 205 | 206 | *synthesize(links: Iterable): Iterable> { 207 | const linksByUrl = byUrl(links) 208 | const urls = [...linksByUrl.keys()].sort( 209 | (a, b) => 210 | (LINK_SPEC_URLS.has(b) ? 1 : 0) - 211 | (LINK_SPEC_URLS.has(a) ? 1 : 0) 212 | ) 213 | for (const url of urls) { 214 | if (!url) continue 215 | if (url === LinkUrl.GRAPHQL_SPEC) continue 216 | const linksForUrl = linksByUrl.get(url)! 217 | let alias: string | null = null 218 | const imports: [string, string][] = [] 219 | for (const link of linksForUrl) { 220 | if (!link.gref.name) { 221 | // a link to the schema tells us the alias, 222 | // if any 223 | alias = link.name 224 | continue 225 | } 226 | if (link.gref.name === '@') 227 | continue // root directive is implict 228 | imports.push([link.name, link.gref.name]) 229 | } 230 | 231 | const args: ConstArgumentNode[] = [{ 232 | kind: Kind.ARGUMENT, 233 | name: { 234 | kind: Kind.NAME, 235 | value: this.urlParam 236 | }, 237 | value: { 238 | kind: Kind.STRING, 239 | value: url.href, 240 | }, 241 | }] 242 | 243 | if (alias === '') { 244 | yield { 245 | kind: Kind.DIRECTIVE, 246 | name: { kind: Kind.NAME, value: "id" }, 247 | arguments: [{ 248 | kind: Kind.ARGUMENT, 249 | name: { 250 | kind: Kind.NAME, 251 | value: "url" 252 | }, 253 | value: { 254 | kind: Kind.STRING, 255 | value: url.href, 256 | }, 257 | }], 258 | gref: ID_DIRECTIVE, 259 | } 260 | continue 261 | } 262 | 263 | if (alias && alias !== url.name) { 264 | args.push({ 265 | kind: Kind.ARGUMENT, 266 | name: { 267 | kind: Kind.NAME, 268 | value: 'as', 269 | }, 270 | value: { 271 | kind: Kind.STRING, 272 | value: alias 273 | }, 274 | }) 275 | } 276 | 277 | if (imports.length) { 278 | args.push({ 279 | kind: Kind.ARGUMENT, 280 | name: { 281 | kind: Kind.NAME, 282 | value: 'import', 283 | }, 284 | value: { 285 | kind: Kind.LIST, 286 | values: imports.map(([alias, name]) => 287 | alias === name 288 | ? { kind: Kind.STRING, value: name } 289 | : { 290 | kind: Kind.OBJECT, 291 | fields: [ 292 | { 293 | kind: Kind.OBJECT_FIELD, 294 | name: { kind: Kind.NAME, value: "name" }, 295 | value: { kind: Kind.STRING, value: name } 296 | }, 297 | { 298 | kind: Kind.OBJECT_FIELD, 299 | name: { kind: Kind.NAME, value: "as" }, 300 | value: { kind: Kind.STRING, value: alias } 301 | } 302 | ] 303 | } 304 | ) 305 | }, 306 | }) 307 | } 308 | 309 | yield { 310 | kind: Kind.DIRECTIVE, 311 | name: this.strap.name, 312 | arguments: args, 313 | gref: GRef.rootDirective(this.url) 314 | } 315 | } 316 | } 317 | } 318 | 319 | const byUrl = groupBy((link: Link) => link.gref.graph) 320 | 321 | -------------------------------------------------------------------------------- /src/names.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode, NameNode } from 'graphql' 2 | 3 | export type LinkPath = [string | null, string] 4 | 5 | export function getPrefix(name: string, sep = '__'): LinkPath { 6 | const idx = name.indexOf(sep) 7 | if (idx === -1) return [null, name] 8 | return [name.substr(0, idx), name.substr(idx + sep.length)] 9 | } 10 | 11 | export function toPrefixed(path: LinkPath): string { 12 | if (path[0] == null) return unAt(path[1]) 13 | return path[0] + '__' + unAt(path[1]) 14 | } 15 | 16 | const AT = '@'.charCodeAt(0) 17 | const unAt = (val: string) => val.charCodeAt(0) === AT ? val.slice(1) : val 18 | 19 | export function scopeNameFor( 20 | node: { kind: ASTNode["kind"], name?: NameNode }, 21 | name = node.name?.value 22 | ) { 23 | if (node.kind === 'Directive' || node.kind === 'DirectiveDefinition') 24 | return '@' + (name ?? '') 25 | return name ?? '' 26 | } 27 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import recall, { replay, use } from '@protoplasm/recall' 2 | import { print, DirectiveNode, DocumentNode, Kind, SchemaDefinitionNode, visit } from 'graphql' 3 | import { Maybe } from 'graphql/jsutils/Maybe' 4 | import { refNodesIn, Defs, isLocatable, Locatable, fill, Def, isRedirect } from './de' 5 | import { id, Link, Linker, LINK_DIRECTIVES } from './linker' 6 | import directives from './directives' 7 | import { GRef, byGref } from './gref' 8 | import Scope, { including, IScope } from './scope' 9 | import { isAst } from './is' 10 | import gql from './gql' 11 | import LinkUrl from './link-url' 12 | import {concat} from './each' 13 | export class Schema implements Defs { 14 | static from(document: DocumentNode, frame: Schema | IScope = Scope.EMPTY) { 15 | if (frame instanceof Schema) 16 | return new this(document, frame.scope) 17 | return new this(document, frame) 18 | } 19 | 20 | static readonly BASIC = Schema.from( 21 | gql `${'builtin:schema/basic'} 22 | @link(url: "https://specs.apollo.dev/link/v1.0") 23 | @link(url: "https://specs.graphql.org", import: """ 24 | @deprecated @specifiedBy 25 | Int Float String Boolean ID 26 | """) 27 | @link(url: "https://specs.apollo.dev/id/v1.0") 28 | `) 29 | 30 | static basic(document: DocumentNode) { 31 | return this.from(document, this.BASIC) 32 | } 33 | 34 | public get scope(): IScope { 35 | return this.frame.child( 36 | scope => { 37 | for (const dir of directives(this.document)) { 38 | const linker = Linker.from(scope, dir) 39 | if (!linker) continue 40 | for (const link of linker.links(dir)) { 41 | scope.add(link) 42 | } 43 | } 44 | const self = selfIn(scope, directives(this.document)) 45 | if (self) { 46 | scope.add({ 47 | ...self, 48 | name: '', 49 | implicit: true, 50 | }) 51 | scope.add({ 52 | ...self, 53 | name: '@' + self.name, 54 | gref: GRef.rootDirective(self.gref.graph), 55 | implicit: true, 56 | }) 57 | } 58 | }) 59 | } 60 | 61 | get url() { return this.scope.url } 62 | get self() { return this.scope.self } 63 | 64 | *[Symbol.iterator](): Iterator { 65 | const {scope} = this 66 | for (const link of scope) { 67 | if (!link.name || !link.gref.name || link.implicit || !link.via) continue 68 | yield { 69 | code: 'Redirect' as const, 70 | gref: GRef.named(link.name, scope.url), 71 | toGref: link.gref, 72 | via: link.via, 73 | } 74 | } 75 | 76 | for (const def of this.document.definitions) { 77 | if (isLocatable(def)) yield scope.denormalize(def) 78 | } 79 | } 80 | 81 | @use(replay) 82 | get refs() { 83 | return refNodesIn(this) 84 | } 85 | 86 | definitions(ref?: GRef): Defs { 87 | if (!ref) return this 88 | if (this.url && !ref.graph) ref = ref.setGraph(this.url) 89 | return byGref(this).get(ref) ?? [] 90 | } 91 | 92 | locate(node: Locatable): GRef { 93 | return this.scope.locate(node) 94 | } 95 | 96 | standardize(...urls: (LinkUrl | string)[]) { 97 | const graphs = new Set(urls.map(u => LinkUrl.from(u)!)) 98 | const standard = Scope.create(scope => { 99 | for (const graph of graphs) { 100 | const {name} = graph 101 | if (!name) 102 | throw new Error('urls sent to standardize must have names') 103 | scope.add({ 104 | name, gref: GRef.schema(graph) 105 | }) 106 | } 107 | }) 108 | const newScope = Scope.create((scope) => { 109 | const flat = this.scope.flat 110 | for (const link of flat) { 111 | if (!graphs.has(link.gref.graph!)) scope.add(link); 112 | } 113 | for (const link of standard) scope.add(link); 114 | }); 115 | return Schema.from({ 116 | kind: Kind.DOCUMENT, 117 | definitions: [ 118 | ...newScope.renormalizeDefs([ 119 | ...newScope.header(), 120 | ...pruneLinks(this) 121 | ]), 122 | ], 123 | }); 124 | } 125 | 126 | compile(atlas?: Defs): Schema { 127 | const extras = [...fill(this, atlas)] 128 | const scope = this.scope.child(including(refNodesIn(extras))).flat 129 | const header = scope.header() 130 | const body = [...pruneLinks(this)] 131 | const linkExtras = [...fill(concat(header, extras), atlas)] 132 | 133 | return Schema.from({ 134 | kind: Kind.DOCUMENT, 135 | definitions: [ 136 | ...scope.renormalizeDefs(concat( 137 | header, 138 | body, 139 | linkExtras, 140 | extras 141 | )) 142 | ] 143 | }).shrinkwrap() 144 | } 145 | 146 | shrinkwrap(): Schema { 147 | const {scope} = this 148 | const safe = new Set() 149 | for (const ref of this.refs) { 150 | const name = this.scope.name(ref.gref) 151 | if (!ref.gref.graph || !name) continue 152 | const [prefix, bare] = name 153 | const link = scope.lookup(prefix ?? bare) 154 | if (!link?.via) continue 155 | safe.add(link.via) 156 | } 157 | const candidates = new Set([...this.scope].map(link => link.via!).filter(Boolean)) 158 | return Schema.from(visit(this.document, { 159 | Directive(dir) { 160 | if (!candidates.has(dir)) return undefined 161 | if (!safe.has(dir)) return null 162 | return undefined 163 | } 164 | }), this.scope.parent) 165 | } 166 | 167 | dangerousRemoveHeaders(): Schema { 168 | return Schema.from({ 169 | kind: Kind.DOCUMENT, 170 | definitions: [...this.scope.renormalizeDefs(pruneLinks(this))] 171 | }, this.scope) 172 | } 173 | 174 | print(): string { 175 | return print(this.document) 176 | } 177 | 178 | protected constructor( 179 | public readonly document: DocumentNode, 180 | public readonly frame: IScope, 181 | ) {} 182 | } 183 | 184 | export default Schema 185 | 186 | const selfIn = recall( 187 | function self(scope: IScope, directives: Iterable): Maybe { 188 | for (const dir of directives) { 189 | const self = id(scope, dir) 190 | if (self) return self 191 | } 192 | return null 193 | } 194 | ) 195 | 196 | export const pruneLinks = replay( 197 | function *pruneLinks(defs: Defs) { 198 | for (const def of defs) { 199 | if (isRedirect(def)) continue 200 | if (isAst(def, Kind.SCHEMA_DEFINITION, Kind.SCHEMA_EXTENSION)) { 201 | if (!def.directives) yield def 202 | const directives = def.directives?.filter(dir => !LINK_DIRECTIVES.has((dir as any).gref)) 203 | if (!directives?.length && !def.operationTypes?.length && !(def as SchemaDefinitionNode).description) 204 | continue 205 | yield { ...def, directives } 206 | continue 207 | } 208 | yield def 209 | } 210 | } 211 | ) -------------------------------------------------------------------------------- /src/scope-map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ScopeMap provides a mutable, heirarchical mapping from K -> V. 3 | */ 4 | export class ScopeMap { 5 | own(key: K): V | undefined { 6 | return this.#entries.get(key) 7 | } 8 | 9 | has(key: K): boolean { 10 | return this.hasOwn(key) || !!this.parent?.has(key) 11 | } 12 | 13 | hasOwn(key: K): boolean { 14 | return this.#entries.has(key) 15 | } 16 | 17 | lookup(key: K): V | undefined { 18 | return this.own(key) ?? this.parent?.lookup(key) 19 | } 20 | 21 | entries(): Iterable<[K, V]> { 22 | return this.#entries.entries() 23 | } 24 | 25 | *visible(): Iterable<[K, V]> { 26 | const seen = new Set() 27 | for (const ent of this.entries()) { 28 | seen.add(ent[0]) 29 | yield ent 30 | } 31 | if (this.parent) for (const ent of this.parent.visible()) { 32 | if (seen.has(ent[0])) continue 33 | seen.add(ent[0]) 34 | yield ent 35 | } 36 | } 37 | 38 | readonly #entries: Map 39 | 40 | set(key: K, value: V): void { 41 | this.#entries.set(key, value) 42 | } 43 | 44 | constructor( 45 | public readonly parent?: ScopeMap, 46 | entries = new Map() 47 | ) { 48 | this.#entries = entries 49 | } 50 | } 51 | 52 | export default ScopeMap -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | import recall, { report, use } from '@protoplasm/recall' 2 | import { ASTNode, DefinitionNode, Kind, SchemaExtensionNode, visit } from 'graphql' 3 | import { Linker, type Link } from './linker' 4 | import { De, Defs, hasRef, isLocatable, isLocated, isRedirect, Locatable, Located, Redirect } from './de' 5 | import GRef from './gref' 6 | import { isAst, hasName } from './is' 7 | import LinkUrl from './link-url' 8 | import { getPrefix, scopeNameFor, toPrefixed } from './names' 9 | import ScopeMap from './scope-map' 10 | import err from './error' 11 | 12 | export const ErrExtraImport = (gref: GRef, node: ASTNode) => 13 | err('ExtraImport', { 14 | message: `extra import of ${gref} ignored`, 15 | gref, node 16 | }) 17 | 18 | /** 19 | * Scopes link local names to global graph locations. 20 | */ 21 | export interface IScope extends Iterable { 22 | readonly url?: LinkUrl 23 | readonly self?: Link 24 | readonly parent?: IScope 25 | readonly linker: Linker 26 | readonly flat: IScope 27 | 28 | own(name: string): Link | undefined 29 | has(name: string): boolean 30 | lookup(name: string): Link | undefined 31 | visible(): Iterable<[string, Link]> 32 | entries(): Iterable<[string, Link]> 33 | header(): [De] | [] 34 | locate(node: Locatable): GRef 35 | name(node: GRef): [string | null, string] | undefined 36 | denormalize(node: T): De 37 | renormalizeDefs(defs: Defs, redirects?: Iterable): Iterable 38 | child(fn: (scope: IScopeMut) => void): Readonly 39 | } 40 | 41 | export interface IScopeMut extends IScope { 42 | add(link: Link): void 43 | } 44 | 45 | export class Scope implements IScope { 46 | static readonly EMPTY = this.create() 47 | 48 | static create(fn?: (scope: IScopeMut) => void, parent?: Scope): IScope { 49 | const child = new this(parent) 50 | if (fn) fn(child as any as IScopeMut) 51 | return Object.freeze(child) 52 | } 53 | 54 | get self() { return this.names.lookup('') } 55 | 56 | get url() { return this.self?.gref.graph } 57 | 58 | locate(node: Locatable): GRef { 59 | if (hasRef(node)) return node.gref 60 | 61 | if (isAst(node, Kind.SCHEMA_DEFINITION, Kind.SCHEMA_EXTENSION)) { 62 | return GRef.schema(this.url) 63 | } 64 | const [ prefix, name ] = getPrefix(node.name?.value ?? '') 65 | 66 | if (prefix) { 67 | // a prefixed__Name 68 | const found = this.lookup(prefix) 69 | if (found) return GRef.canon(scopeNameFor(node, name), found.gref.graph) 70 | } 71 | 72 | if (isAst(node, Kind.DIRECTIVE) && !prefix) { 73 | const named = this.lookup(scopeNameFor(node))?.gref 74 | if (named) return named 75 | 76 | const maybeNs = this.lookup(name) 77 | if (maybeNs?.gref.isSchema()) { 78 | return GRef.rootDirective(maybeNs.gref.graph) 79 | } 80 | } 81 | 82 | // if there was no prefix OR the prefix wasn't found, 83 | // treat the entire name as a local name 84 | // 85 | // this means that prefixed__Names will be interpreted 86 | // as local names if and only if the prefix has not been `@link`ed 87 | // 88 | // this allows for universality — it is always possible to represent 89 | // any api with a core schema by appropriately selecting link names 90 | // with `@link(as:)` or `@link(import:)`, even if the desired 91 | // api contains double-underscored names (odd choice, but you do you) 92 | // 93 | // FIXME: make namespace escape explicit with @link(!url, import:) 94 | return this.lookup(scopeNameFor(node))?.gref ?? GRef.canon(scopeNameFor(node), this.url) 95 | } 96 | 97 | header(): [De] | [] { 98 | const directives = [...this.linker.synthesize(this)] 99 | if (directives.length) { 100 | return [{ kind: Kind.SCHEMA_EXTENSION, directives, gref: GRef.schema(this.url) }] 101 | } 102 | return [] 103 | } 104 | 105 | name(gref: GRef): [string | null, string] | undefined { 106 | const bareName = this.reverse.lookup(gref) 107 | if (bareName) return [null, bareName] 108 | 109 | const prefix = this.reverse.lookup(gref.setName('')) 110 | if (prefix) return [prefix, gref.name] 111 | 112 | return 113 | } 114 | 115 | @use(recall) 116 | denormalize(node: T): De { 117 | const self = this 118 | return visit(node, { 119 | enter(node: T, _: any, ): De | undefined { 120 | if (isAst(node, Kind.INPUT_VALUE_DEFINITION)) return 121 | if (isAst(node, Kind.ENUM_VALUE_DEFINITION)) return 122 | if (isLocatable(node)) { 123 | return { ...node, gref: self.locate(node) } as De 124 | } 125 | return 126 | } 127 | }) as De 128 | } 129 | 130 | @use(recall) 131 | renormalize(node: De, redirects?: Readonly>): T { 132 | const self = this 133 | return visit(node, { 134 | enter(node: T, _: any, ): T | null | undefined { 135 | if (isAst(node, Kind.INPUT_VALUE_DEFINITION)) return // todo - remove? 136 | if (!hasName(node) || !isLocated(node)) return 137 | const path = self.name(redirect(node.gref, redirects)) 138 | if (!path) return 139 | return { 140 | ...node, 141 | name: { ...node.name, value: toPrefixed(path) } 142 | } 143 | } 144 | }) as T 145 | } 146 | 147 | *renormalizeDefs(defs: Defs): Iterable { 148 | const redirects = new Map() 149 | const onlyDefs: De[] = [] 150 | for (const redir of defs) if (isRedirect(redir)) { 151 | const existing = redirects.get(redir.gref) 152 | if (existing) { 153 | if (existing.toGref !== redir.toGref) 154 | report(ErrExtraImport(redir.gref, redir.via)) 155 | continue 156 | } 157 | redirects.set(redir.gref, redir) 158 | } else { onlyDefs.push(redir) } 159 | 160 | for (const def of onlyDefs) 161 | if (isRedirect(def)) continue 162 | else yield this.renormalize(def, redirects) 163 | } 164 | 165 | *[Symbol.iterator]() { 166 | for (const ent of this.entries()) yield ent[1] 167 | } 168 | 169 | get flat() { 170 | return Scope.create(scope => { 171 | for (const [_, link] of this.visible()) 172 | scope.add(link) 173 | }) 174 | } 175 | 176 | own(name: string) { return this.names.own(name) } 177 | has(name: string) { return this.names.has(name) } 178 | hasOwn(name: string) { return this.names.hasOwn(name) } 179 | lookup(name: string) { return this.names.lookup(name) } 180 | visible() { return this.names.visible() } 181 | entries() { return this.names.entries() } 182 | 183 | child(fn?: (scope: IScopeMut) => void): IScope { 184 | return Scope.create(fn, this) 185 | } 186 | 187 | clone(fn?: (scope: IScopeMut) => void): IScope { 188 | return Scope.create(scope => { 189 | for (const [_, link] of this.entries()) 190 | scope.add(link) 191 | if (fn) fn(scope) 192 | }, this.parent) 193 | } 194 | 195 | get linker(): Linker { 196 | for (const link of this) { 197 | const linker = link.linker ? Linker.bootstrap(link.linker) : null 198 | if (linker) return linker 199 | } 200 | return this.parent?.linker ?? Linker.DEFAULT 201 | } 202 | 203 | //@ts-ignore — accessible via IScopeMut 204 | private add(link: Link): void { 205 | this.names.set(link.name, link) 206 | this.reverse.set(link.gref, link.name) 207 | } 208 | 209 | private readonly names: ScopeMap = new ScopeMap(this.parent?.names) 210 | private readonly reverse: ScopeMap = new ScopeMap(this.parent?.reverse) 211 | 212 | private constructor(public readonly parent?: Scope) {} 213 | } 214 | 215 | export default Scope 216 | 217 | /** 218 | * Return a Scope mutation which includes links to the provided 219 | * refs. 220 | * 221 | * This can be used with scope.child, scope.clone, or Scope.create: 222 | * 223 | * ```typescript 224 | * const scope = Scope.create(including(someRefs)) 225 | * ``` 226 | * 227 | * The resulting Scope will be able to `name` all refs 228 | * provided. 229 | * 230 | * @param refs 231 | */ 232 | export const including = (refs: Iterable) => (scope: IScopeMut) => { 233 | for (const node of refs) { 234 | if (isRedirect(node)) { 235 | const src = scope.name(node.gref) 236 | if (!src) continue 237 | const [prefix, name] = src 238 | if (prefix) continue 239 | scope.add({ 240 | ...scope.lookup(name), 241 | name, 242 | gref: node.toGref, 243 | }) 244 | } else { 245 | const graph = node.gref.graph 246 | if (!graph) continue 247 | const found = scope.name(node.gref) 248 | if (found) continue 249 | addGraph(graph) 250 | } 251 | } 252 | 253 | function addGraph(graph: LinkUrl) { 254 | for (const name of graph.suggestNames()) { 255 | if (scope.has(name)) continue 256 | scope.add({ 257 | name, gref: GRef.schema(graph) 258 | }) 259 | break 260 | } 261 | } 262 | } 263 | 264 | 265 | function redirect(gref: GRef, redirects?: Readonly>): GRef { 266 | if (!redirects) return gref 267 | while (redirects.has(gref)) gref = redirects.get(gref)!.toGref 268 | return gref 269 | } 270 | -------------------------------------------------------------------------------- /src/snapshot-serializers/ast.ts: -------------------------------------------------------------------------------- 1 | import { type ASTNode, print as printNode, Location, TokenKind } from 'graphql' 2 | import { hasRef } from '../de' 3 | 4 | /** 5 | * Serialize AST nodes as a snippet of the source. 6 | * 7 | * This keeps snapshots more readable, as AST nodes typically have a whole 8 | * subtree attached to them. 9 | */ 10 | export const test = (val: any) => typeof val?.kind === 'string' 11 | export const print = (val: ASTNode) => { 12 | const gref = hasRef(val) 13 | ? `<${val.gref?.toString() ?? ''}>` 14 | : '' 15 | if (!val.loc) return `${gref}[+] ${printNode(val)}` 16 | const loc = skipDescription(val.loc) 17 | const {line} = loc 18 | let start = loc 19 | let end = loc 20 | while (start.prev && start.prev.line === line) 21 | start = start.prev 22 | while (end.next && end.next.line === line) 23 | end = end.next 24 | const text = val.loc.source.body.substring(start.start, end.end) 25 | const col = loc.start - start.start 26 | const head = text.substring(0, col) 27 | const tail = text.substring(col) 28 | return `${gref}[${val.loc.source.name}] ${head}👉${tail}` 29 | } 30 | 31 | function skipDescription(loc: Location) { 32 | if (loc.startToken.kind === TokenKind.BLOCK_STRING) return loc.startToken.next! 33 | return loc.startToken 34 | } -------------------------------------------------------------------------------- /src/snapshot-serializers/gref.ts: -------------------------------------------------------------------------------- 1 | import {GRef} from '../gref' 2 | import {LinkUrl} from '../link-url' 3 | 4 | export const test = (val: any) => 5 | val instanceof GRef 6 | || val instanceof LinkUrl 7 | export const print = (val: GRef | LinkUrl) => 8 | `${val.constructor.name} <${val.toString()}>` 9 | -------------------------------------------------------------------------------- /src/snapshot-serializers/iterable.ts: -------------------------------------------------------------------------------- 1 | 2 | export const test = (val: any) => !!val && typeof val === 'object' && !Array.isArray(val) && typeof val[Symbol.iterator] === 'function' 3 | export const print = (val: any, serialize: any, indent: any) => 4 | [...lines(val, serialize, indent)].join('\n') 5 | 6 | function *lines(val: any, serialize: any, indent: any) { 7 | yield (val.constructor?.name || 'Iterable') + ' [' 8 | for (const item of val) { 9 | yield indent(serialize(item)) + ',' 10 | } 11 | yield ']' 12 | } -------------------------------------------------------------------------------- /src/snapshot-serializers/raw.ts: -------------------------------------------------------------------------------- 1 | export class Raw { 2 | constructor(public readonly text: string) {} 3 | } 4 | export const raw = (text: string) => new Raw(text) 5 | export default raw 6 | 7 | export const test = (val: any) => val instanceof Raw 8 | export const print = (raw: Raw) => raw.text 9 | -------------------------------------------------------------------------------- /src/snapshot-serializers/redirect.ts: -------------------------------------------------------------------------------- 1 | import { Redirect } from "../de" 2 | 3 | /** 4 | * Serialize Redirects 5 | */ 6 | export const test = (val: any) => val?.code === 'Redirect' 7 | export const print = (val: Redirect, snap: any) => 8 | `${snap(val.gref)} => ${snap(val.toGref)} (via ${snap(val.via)})` -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | import recall, { use } from "@protoplasm/recall" 2 | 3 | /** 4 | * Versions are a (major, minor) number pair. 5 | * 6 | * Versions implement `PartialOrd` and `Ord`, which orders them by major and then 7 | * minor version. Be aware that this ordering does *not* imply compatibility. For 8 | * example, `Version(2, 0) > Version(1, 9)`, but an implementation of `Version(2, 0)` 9 | * *cannot* satisfy a request for `Version(1, 9)`. To check for version compatibility, 10 | * use [the `satisfies` method](#satisfies). 11 | */ 12 | export class Version { 13 | /** 14 | * Parse a version specifier of the form "v(major).(minor)" or throw 15 | * 16 | * # Example 17 | * ``` 18 | * expect(Version.parse('v1.0')).toEqual(new Version(1, 0)) 19 | * expect(Version.parse('v0.1')).toEqual(new Version(0, 1)) 20 | * expect(Version.parse("v987.65432")).toEqual(new Version(987, 65432)) 21 | * ``` 22 | */ 23 | public static parse(input?: string): Version | null { 24 | if (!input) return null 25 | const match = input.match(this.VERSION_RE) 26 | if (!match) return null 27 | return this.canon(+match[1], +match[2]) 28 | } 29 | 30 | public static from(input: string | [number, number] | Version): Version | null { 31 | if (input instanceof this) return input 32 | if (typeof input === 'string') return this.parse(input) 33 | if (Array.isArray(input)) return this.canon(...input) 34 | return null 35 | } 36 | 37 | @use(recall) 38 | public static canon(major: number, minor: number): Version { 39 | return new this(major, minor) 40 | } 41 | 42 | private constructor(public readonly major: number, public readonly minor: number) {} 43 | 44 | /** 45 | * a string indicating this version's compatibility series. for release versions (>= 1.0), this 46 | * will be a string like "v1.x", "v2.x", and so on. experimental minor updates carry no expectation 47 | * of compatibility, so those will just return the same thing as `this.toString()`. 48 | */ 49 | public get series() { 50 | const {major} = this 51 | return major > 0 ? `${major}.x` : String(this) 52 | } 53 | 54 | /** 55 | * return the string version tag, like "v2.9" 56 | * 57 | * @returns a version tag 58 | */ 59 | public toString() { 60 | return `v${this.major}.${this.minor}` 61 | } 62 | 63 | /** 64 | * return true iff this version is exactly equal to the provided version 65 | * 66 | * @param other the version to compare 67 | * @returns true if versions are strictly equal 68 | */ 69 | public equals(other?: Version) { 70 | if (!other) return false 71 | return this.major === other.major && this.minor === other.minor 72 | } 73 | 74 | 75 | /** 76 | * Return true if and only if this Version satisfies the `required` version 77 | * 78 | * # Example 79 | * ``` 80 | * expect(new Version(1, 0).satisfies(new Version(1, 0))).toBe(true) 81 | * expect(new Version(1, 2).satisfies(new Version(1, 0))).toBe(true) 82 | * expect(new Version(2, 0).satisfies(new Version(1, 9))).toBe(false) 83 | * expect(new Version(0, 9).satisfies(new Version(0, 8))).toBe(false) 84 | * ``` 85 | **/ 86 | satisfies(required?: Version): boolean { 87 | // any version satisfies null 88 | if (!required) return true 89 | const {major, minor} = this 90 | const {major: rMajor, minor: rMinor} = required 91 | return rMajor == major && ( 92 | major == 0 93 | ? rMinor == minor 94 | : rMinor <= minor 95 | ) 96 | } 97 | 98 | private static VERSION_RE = /^v(\d+)\.(\d+)$/ 99 | } 100 | 101 | export default Version 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "removeComments": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedParameters": true, 16 | "noUnusedLocals": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "lib": ["es2019", "esnext.asynciterable"], 19 | "types": ["node"], 20 | "baseUrl": ".", 21 | "paths": { 22 | "*" : ["types/*"] 23 | }, 24 | "rootDir": "./src", 25 | "outDir": "./dist", 26 | "noEmitOnError": false, 27 | "experimentalDecorators": true 28 | }, 29 | "exclude": ["**/__tests__", "dist"] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": ["node", "jest"], 6 | "paths": { 7 | "__mocks__/*" : ["__mocks__/*"], 8 | } 9 | } 10 | } 11 | --------------------------------------------------------------------------------