├── .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