@ultimabase/graphql 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +4 -2
- package/dist/data-types.d.ts +15 -0
- package/dist/data-types.d.ts.map +1 -0
- package/dist/data-types.js +53 -0
- package/dist/data.d.ts +34 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +207 -0
- package/dist/depth.d.ts +6 -0
- package/dist/depth.d.ts.map +1 -0
- package/dist/depth.js +67 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/functions.d.ts +28 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +195 -0
- package/dist/graphiql.d.ts +3 -0
- package/dist/graphiql.d.ts.map +1 -0
- package/dist/graphiql.js +46 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +165 -0
- package/dist/inputs.d.ts +20 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +236 -0
- package/dist/json-fields.d.ts +12 -0
- package/dist/json-fields.d.ts.map +1 -0
- package/dist/json-fields.js +66 -0
- package/dist/json-schema.d.ts +7 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +16 -0
- package/dist/live-stream.d.ts +59 -0
- package/dist/live-stream.d.ts.map +1 -0
- package/dist/live-stream.js +136 -0
- package/dist/mutations.d.ts +9 -0
- package/dist/mutations.d.ts.map +1 -0
- package/dist/mutations.js +228 -0
- package/dist/names.d.ts +8 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +14 -0
- package/dist/outputs.d.ts +16 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +177 -0
- package/dist/overlap.d.ts +5 -0
- package/dist/overlap.d.ts.map +1 -0
- package/dist/overlap.js +82 -0
- package/dist/pages.d.ts +11 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +22 -0
- package/dist/registry.d.ts +80 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +97 -0
- package/dist/reread.d.ts +9 -0
- package/dist/reread.d.ts.map +1 -0
- package/dist/reread.js +116 -0
- package/dist/rows.d.ts +23 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +106 -0
- package/dist/scalars.d.ts +13 -0
- package/dist/scalars.d.ts.map +1 -0
- package/dist/scalars.js +88 -0
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +130 -0
- package/dist/select.d.ts +24 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +152 -0
- package/dist/sockets.d.ts +25 -0
- package/dist/sockets.d.ts.map +1 -0
- package/dist/sockets.js +234 -0
- package/dist/subscriptions.d.ts +14 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +242 -0
- package/package.json +58 -3
- package/src/data-types.ts +100 -0
- package/src/data.ts +366 -0
- package/src/depth.ts +98 -0
- package/src/errors.ts +48 -0
- package/src/functions.ts +291 -0
- package/src/graphiql.ts +54 -0
- package/src/index.ts +231 -0
- package/src/inputs.ts +348 -0
- package/src/json-fields.ts +95 -0
- package/src/json-schema.ts +21 -0
- package/src/live-stream.ts +192 -0
- package/src/mutations.ts +317 -0
- package/src/names.ts +25 -0
- package/src/outputs.ts +267 -0
- package/src/overlap.ts +97 -0
- package/src/pages.ts +39 -0
- package/src/registry.ts +189 -0
- package/src/reread.ts +159 -0
- package/src/rows.ts +151 -0
- package/src/scalars.ts +110 -0
- package/src/schema.ts +189 -0
- package/src/select.ts +230 -0
- package/src/sockets.ts +291 -0
- package/src/subscriptions.ts +334 -0
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/graphql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GraphQL for UltimaBase: a Relay schema generated from the models, served by the HTTP plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/graphql",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/graphql"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.7.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/test"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"graphql-http": "1.23.1",
|
|
35
|
+
"graphql-ws": "6.4.1",
|
|
36
|
+
"ws": "8.22.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@ultimabase/http": "^0.1.0",
|
|
40
|
+
"@ultimabase/realtime": "^0.1.0",
|
|
41
|
+
"graphql": "^17.0.2",
|
|
42
|
+
"ultimabase": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@ultimabase/realtime": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/pg": "8.23.1",
|
|
51
|
+
"@types/ws": "8.18.2",
|
|
52
|
+
"@ultimabase/http": "0.1.0",
|
|
53
|
+
"@ultimabase/postgres": "0.1.0",
|
|
54
|
+
"@ultimabase/realtime": "0.1.0",
|
|
55
|
+
"graphql": "17.0.2",
|
|
56
|
+
"pg": "8.23.1",
|
|
57
|
+
"ultimabase": "0.1.0",
|
|
58
|
+
"vite": "8.3.4",
|
|
59
|
+
"vitest": "5.0.5"
|
|
60
|
+
}
|
|
6
61
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The data types functions and json fields give (D-45, D-125), each name
|
|
2
|
+
// given one shape, none named like a model.
|
|
3
|
+
import type {
|
|
4
|
+
GraphQLInputObjectType,
|
|
5
|
+
GraphQLNamedType,
|
|
6
|
+
GraphQLObjectType,
|
|
7
|
+
} from 'graphql'
|
|
8
|
+
import { GraphQLEnumType } from 'graphql'
|
|
9
|
+
import { Refused } from 'ultimabase'
|
|
10
|
+
|
|
11
|
+
type Shaped<T> = {
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly owner: string
|
|
14
|
+
readonly type: T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The named types of functions and json fields, each given one shape. */
|
|
18
|
+
export class DataTypes {
|
|
19
|
+
readonly #objects = new Map<string, Shaped<GraphQLObjectType>>()
|
|
20
|
+
readonly #inputs = new Map<string, Shaped<GraphQLInputObjectType>>()
|
|
21
|
+
readonly #enums = new Map<string, Shaped<GraphQLEnumType>>()
|
|
22
|
+
readonly #models: ReadonlySet<string>
|
|
23
|
+
/** Each named type, and the first function or field to give it. */
|
|
24
|
+
readonly owners: Map<string, string> = new Map()
|
|
25
|
+
|
|
26
|
+
constructor(models: Iterable<string>) {
|
|
27
|
+
this.#models = new Set(models)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
object(
|
|
31
|
+
name: string,
|
|
32
|
+
key: string,
|
|
33
|
+
owner: string,
|
|
34
|
+
build: () => GraphQLObjectType,
|
|
35
|
+
): GraphQLObjectType {
|
|
36
|
+
return this.#shaped(this.#objects, name, key, owner, build)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
input(
|
|
40
|
+
name: string,
|
|
41
|
+
key: string,
|
|
42
|
+
owner: string,
|
|
43
|
+
build: () => GraphQLInputObjectType,
|
|
44
|
+
): GraphQLInputObjectType {
|
|
45
|
+
return this.#shaped(this.#inputs, name, key, owner, build)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
enum(
|
|
49
|
+
name: string,
|
|
50
|
+
values: readonly string[],
|
|
51
|
+
owner: string,
|
|
52
|
+
description?: string,
|
|
53
|
+
): GraphQLEnumType {
|
|
54
|
+
const build = () =>
|
|
55
|
+
new GraphQLEnumType({
|
|
56
|
+
name,
|
|
57
|
+
description,
|
|
58
|
+
values: Object.fromEntries(values.map((value) => [value, { value }])),
|
|
59
|
+
})
|
|
60
|
+
return this.#shaped(this.#enums, name, values.join(), owner, build)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The type named `name`, if one was given. */
|
|
64
|
+
named(name: string): GraphQLNamedType | undefined {
|
|
65
|
+
return (
|
|
66
|
+
this.#objects.get(name)?.type ??
|
|
67
|
+
this.#inputs.get(name)?.type ??
|
|
68
|
+
this.#enums.get(name)?.type
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#shaped<T>(
|
|
73
|
+
cache: Map<string, Shaped<T>>,
|
|
74
|
+
name: string,
|
|
75
|
+
key: string,
|
|
76
|
+
owner: string,
|
|
77
|
+
build: () => T,
|
|
78
|
+
): T {
|
|
79
|
+
if (this.#models.has(name)) {
|
|
80
|
+
throw new Refused(`${owner}: the type ${name} is named like a model.`)
|
|
81
|
+
}
|
|
82
|
+
const known = cache.get(name)
|
|
83
|
+
const other = this.owners.get(name)
|
|
84
|
+
if (known === undefined && other !== undefined) {
|
|
85
|
+
throw new Refused(
|
|
86
|
+
`${other} and ${owner} give the type ${name} two shapes; name each once.`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (known === undefined) {
|
|
90
|
+
const type = build()
|
|
91
|
+
cache.set(name, { key, owner, type })
|
|
92
|
+
this.owners.set(name, owner)
|
|
93
|
+
return type
|
|
94
|
+
}
|
|
95
|
+
if (known.key === key) return known.type
|
|
96
|
+
throw new Refused(
|
|
97
|
+
`${known.owner} and ${owner} give the type ${name} two shapes; name each once.`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// A schema's GraphQL types, from its standard JSON Schema export (D-45,
|
|
2
|
+
// D-109): what a function takes and returns as data, and a json field
|
|
3
|
+
// (D-125). A schema given an id is one shared type; any other object or enum
|
|
4
|
+
// is named from its path.
|
|
5
|
+
import type { Node } from '@ultimabase/http/internal'
|
|
6
|
+
import { isNode, jsonSchemaOf } from '@ultimabase/http/internal'
|
|
7
|
+
import type {
|
|
8
|
+
GraphQLEnumType,
|
|
9
|
+
GraphQLFieldConfigArgumentMap,
|
|
10
|
+
GraphQLNullableInputType,
|
|
11
|
+
GraphQLNullableOutputType,
|
|
12
|
+
GraphQLNullableType,
|
|
13
|
+
GraphQLScalarType,
|
|
14
|
+
} from 'graphql'
|
|
15
|
+
import {
|
|
16
|
+
GraphQLBoolean,
|
|
17
|
+
GraphQLFloat,
|
|
18
|
+
GraphQLInputObjectType,
|
|
19
|
+
GraphQLInt,
|
|
20
|
+
GraphQLList,
|
|
21
|
+
GraphQLNonNull,
|
|
22
|
+
GraphQLObjectType,
|
|
23
|
+
GraphQLString,
|
|
24
|
+
} from 'graphql'
|
|
25
|
+
import type { Side } from './json-schema.ts'
|
|
26
|
+
import { defsOf, exportOf } from './json-schema.ts'
|
|
27
|
+
import { upper } from './names.ts'
|
|
28
|
+
import type { Context } from './registry.ts'
|
|
29
|
+
import { DateScalar, DateTime, JSONScalar } from './scalars.ts'
|
|
30
|
+
import type { DataTypes } from './data-types.ts'
|
|
31
|
+
import { Refused } from 'ultimabase'
|
|
32
|
+
|
|
33
|
+
const reserved = new Set(['true', 'false', 'null'])
|
|
34
|
+
const named = (value: string): boolean =>
|
|
35
|
+
/^[_A-Za-z][_0-9A-Za-z]*$/.test(value) && !value.startsWith('__')
|
|
36
|
+
const isName = (value: unknown): value is string =>
|
|
37
|
+
typeof value === 'string' && named(value)
|
|
38
|
+
const isValue = (value: unknown): value is string =>
|
|
39
|
+
isName(value) && !reserved.has(value)
|
|
40
|
+
/** A node's description, as `.describe()` writes it (D-150). */
|
|
41
|
+
const describedBy = (node: Node): string | undefined => {
|
|
42
|
+
const { description } = node
|
|
43
|
+
return typeof description === 'string' ? description : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where a node is: who a message names, a type's name, and the path. */
|
|
47
|
+
export type Path = {
|
|
48
|
+
readonly owner: string
|
|
49
|
+
readonly name: string
|
|
50
|
+
/** `Input` under a shared input type: `AddressInput`, `AddressGeoInput`. */
|
|
51
|
+
readonly suffix: string
|
|
52
|
+
readonly at: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stops startup on a name GraphQL refuses. */
|
|
56
|
+
export function checkName(name: string, where: string): string {
|
|
57
|
+
if (named(name)) return name
|
|
58
|
+
throw new Refused(`${where}: ${name} is not a GraphQL name.`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Leaf = GraphQLScalarType | GraphQLEnumType
|
|
62
|
+
export type Typed<T> = { readonly type: T; readonly nullable: boolean }
|
|
63
|
+
|
|
64
|
+
/** How one side builds its types, and the JSON Schema's shared ones. */
|
|
65
|
+
interface Scope<T extends GraphQLNullableType> {
|
|
66
|
+
readonly side: Side
|
|
67
|
+
/** A json field's: `JSON` where a function's would stop startup (D-125). */
|
|
68
|
+
readonly lenient: boolean
|
|
69
|
+
readonly defs: Node
|
|
70
|
+
readonly types: DataTypes
|
|
71
|
+
readonly leaf: (type: Leaf) => T
|
|
72
|
+
readonly list: (of: Typed<T>) => T
|
|
73
|
+
readonly object: (node: Node, properties: Node, path: Path) => T
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const union = (path: Path): Error =>
|
|
77
|
+
new Refused(
|
|
78
|
+
`${path.owner}: ${path.at} is a union, which GraphQL cannot type; keep one type, or one and null.`,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** `JSON` in a json field's types, or startup stops for a function's. */
|
|
82
|
+
function untyped<T extends GraphQLNullableType>(
|
|
83
|
+
scope: Scope<T>,
|
|
84
|
+
nullable: boolean,
|
|
85
|
+
refused: () => Error,
|
|
86
|
+
): Typed<T> {
|
|
87
|
+
if (!scope.lenient) throw refused()
|
|
88
|
+
return { type: scope.leaf(JSONScalar), nullable }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function refType<T extends GraphQLNullableType>(
|
|
92
|
+
ref: string,
|
|
93
|
+
path: Path,
|
|
94
|
+
scope: Scope<T>,
|
|
95
|
+
): Typed<T> {
|
|
96
|
+
if (ref === '#') {
|
|
97
|
+
return untyped(
|
|
98
|
+
scope,
|
|
99
|
+
false,
|
|
100
|
+
() =>
|
|
101
|
+
new Refused(
|
|
102
|
+
`${path.owner}: ${path.at} refers to its own schema; name it with .meta({ id }).`,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
const id = ref.startsWith('#/$defs/') ? ref.slice(8) : ''
|
|
107
|
+
const node = scope.defs[id]
|
|
108
|
+
if (!isNode(node)) {
|
|
109
|
+
return untyped(
|
|
110
|
+
scope,
|
|
111
|
+
false,
|
|
112
|
+
() =>
|
|
113
|
+
new Refused(`${path.owner}: ${path.at} refers to ${ref}, not a $defs.`),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
if (!named(id)) {
|
|
117
|
+
return untyped(
|
|
118
|
+
scope,
|
|
119
|
+
false,
|
|
120
|
+
() =>
|
|
121
|
+
new Refused(
|
|
122
|
+
`${path.owner}: ${path.at} refers to ${id}, which is not a GraphQL name; name it with .meta({ id }).`,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
const suffix = scope.side === 'input' ? 'Input' : ''
|
|
127
|
+
return dataType(node, { ...path, name: id, suffix }, scope)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function enumType(
|
|
131
|
+
node: Node,
|
|
132
|
+
values: readonly unknown[],
|
|
133
|
+
path: Path,
|
|
134
|
+
types: DataTypes,
|
|
135
|
+
) {
|
|
136
|
+
const names = values.filter((value) => value !== null)
|
|
137
|
+
if (!names.every(isValue)) return GraphQLString
|
|
138
|
+
return types.enum(path.name, names, path.owner, describedBy(node))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function typeOf<T extends GraphQLNullableType>(
|
|
142
|
+
type: unknown,
|
|
143
|
+
node: Node,
|
|
144
|
+
path: Path,
|
|
145
|
+
scope: Scope<T>,
|
|
146
|
+
): T {
|
|
147
|
+
const { const: only, enum: listed } = node
|
|
148
|
+
const values: readonly unknown[] =
|
|
149
|
+
only === undefined ? (Array.isArray(listed) ? listed : []) : [only]
|
|
150
|
+
if (type === 'boolean') return scope.leaf(GraphQLBoolean)
|
|
151
|
+
// A literal number is an `Int` when each of its values is whole.
|
|
152
|
+
if (type === 'integer' || type === 'number') {
|
|
153
|
+
const whole = values.length > 0 && values.every(Number.isInteger)
|
|
154
|
+
return scope.leaf(type === 'integer' || whole ? GraphQLInt : GraphQLFloat)
|
|
155
|
+
}
|
|
156
|
+
if (only !== undefined) return scope.leaf(GraphQLString)
|
|
157
|
+
if (values.length > 0)
|
|
158
|
+
return scope.leaf(enumType(node, values, path, scope.types))
|
|
159
|
+
if (type === 'string') {
|
|
160
|
+
const format = node['format']
|
|
161
|
+
return scope.leaf(
|
|
162
|
+
format === 'date-time'
|
|
163
|
+
? DateTime
|
|
164
|
+
: format === 'date'
|
|
165
|
+
? DateScalar
|
|
166
|
+
: GraphQLString,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (type === 'array') {
|
|
170
|
+
const items = isNode(node['items']) ? node['items'] : {}
|
|
171
|
+
return scope.list(dataType(items, { ...path, at: `${path.at}[]` }, scope))
|
|
172
|
+
}
|
|
173
|
+
const { properties } = node
|
|
174
|
+
if (type === 'object' && isNode(properties)) {
|
|
175
|
+
const keys = Object.keys(properties)
|
|
176
|
+
// A json field's key GraphQL cannot name makes its object `JSON`.
|
|
177
|
+
const typed = !scope.lenient || keys.every(named)
|
|
178
|
+
if (keys.length > 0 && typed) return scope.object(node, properties, path)
|
|
179
|
+
}
|
|
180
|
+
return scope.leaf(JSONScalar)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A node's GraphQL type, and whether it accepts `null`. */
|
|
184
|
+
function dataType<T extends GraphQLNullableType>(
|
|
185
|
+
node: Node,
|
|
186
|
+
path: Path,
|
|
187
|
+
scope: Scope<T>,
|
|
188
|
+
): Typed<T> {
|
|
189
|
+
const ref = node['$ref']
|
|
190
|
+
if (typeof ref === 'string') return refType(ref, path, scope)
|
|
191
|
+
const variants: unknown = node['anyOf'] ?? node['oneOf']
|
|
192
|
+
if (Array.isArray(variants)) {
|
|
193
|
+
const others: readonly unknown[] = variants.filter(
|
|
194
|
+
(variant) => !isNode(variant) || variant['type'] !== 'null',
|
|
195
|
+
)
|
|
196
|
+
const [only] = others
|
|
197
|
+
if (others.length !== 1 || variants.length !== 2 || !isNode(only)) {
|
|
198
|
+
return untyped(scope, others.length < variants.length, () => union(path))
|
|
199
|
+
}
|
|
200
|
+
return { type: dataType(only, path, scope).type, nullable: true }
|
|
201
|
+
}
|
|
202
|
+
const listed: readonly unknown[] = Array.isArray(node['type'])
|
|
203
|
+
? node['type']
|
|
204
|
+
: [node['type']]
|
|
205
|
+
const types = listed.filter((type) => type !== undefined && type !== 'null')
|
|
206
|
+
const nullable = types.length < listed.length
|
|
207
|
+
if (types.length > 1) return untyped(scope, nullable, () => union(path))
|
|
208
|
+
return { type: typeOf(types[0], node, path, scope), nullable }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Each property's type, required when it always holds a value. */
|
|
212
|
+
type Property<T extends GraphQLNullableType> = {
|
|
213
|
+
readonly type: T | GraphQLNonNull<T>
|
|
214
|
+
readonly description: string | undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function propertiesOf<T extends GraphQLNullableType>(
|
|
218
|
+
node: Node,
|
|
219
|
+
properties: Node,
|
|
220
|
+
path: Path,
|
|
221
|
+
scope: Scope<T>,
|
|
222
|
+
): (readonly [string, Property<T>])[] {
|
|
223
|
+
const listed: readonly unknown[] = Array.isArray(node['required'])
|
|
224
|
+
? node['required']
|
|
225
|
+
: []
|
|
226
|
+
return Object.entries(properties).map(([key, value]) => {
|
|
227
|
+
const at = `${path.at}.${key}`
|
|
228
|
+
checkName(key, `${path.owner}: ${at}`)
|
|
229
|
+
const child = { ...path, name: `${path.name}${upper(key)}`, at }
|
|
230
|
+
const property = isNode(value) ? value : {}
|
|
231
|
+
const { type, nullable } = dataType(property, child, scope)
|
|
232
|
+
// A default fills an input the client leaves out.
|
|
233
|
+
const defaulted =
|
|
234
|
+
scope.side === 'input' && property['default'] !== undefined
|
|
235
|
+
const required = listed.includes(key) && !nullable && !defaulted
|
|
236
|
+
return [
|
|
237
|
+
key,
|
|
238
|
+
{
|
|
239
|
+
type: required ? new GraphQLNonNull(type) : type,
|
|
240
|
+
description: describedBy(property),
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** An object type's fields, read when the schema is built. */
|
|
247
|
+
const fieldsOf =
|
|
248
|
+
<T extends GraphQLNullableType>(
|
|
249
|
+
node: Node,
|
|
250
|
+
properties: Node,
|
|
251
|
+
path: Path,
|
|
252
|
+
scope: Scope<T>,
|
|
253
|
+
) =>
|
|
254
|
+
(): { [key: string]: Property<T> } =>
|
|
255
|
+
Object.fromEntries(propertiesOf(node, properties, path, scope))
|
|
256
|
+
|
|
257
|
+
const listOf = <T extends GraphQLNullableType>({ type, nullable }: Typed<T>) =>
|
|
258
|
+
new GraphQLList(nullable ? type : new GraphQLNonNull(type))
|
|
259
|
+
|
|
260
|
+
/** Each side's scope for `root`'s types. */
|
|
261
|
+
function scopesOf(root: Node, types: DataTypes, lenient: boolean) {
|
|
262
|
+
const shared = {
|
|
263
|
+
lenient,
|
|
264
|
+
defs: defsOf(root),
|
|
265
|
+
types,
|
|
266
|
+
leaf: (type: Leaf) => type,
|
|
267
|
+
}
|
|
268
|
+
const output: Scope<GraphQLNullableOutputType> = {
|
|
269
|
+
...shared,
|
|
270
|
+
side: 'output',
|
|
271
|
+
list: listOf,
|
|
272
|
+
object: (node, properties, path) =>
|
|
273
|
+
types.object(
|
|
274
|
+
path.name,
|
|
275
|
+
JSON.stringify(node),
|
|
276
|
+
path.owner,
|
|
277
|
+
() =>
|
|
278
|
+
new GraphQLObjectType<unknown, Context>({
|
|
279
|
+
name: path.name,
|
|
280
|
+
description: describedBy(node),
|
|
281
|
+
fields: fieldsOf(node, properties, path, output),
|
|
282
|
+
}),
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
const input: Scope<GraphQLNullableInputType> = {
|
|
286
|
+
...shared,
|
|
287
|
+
side: 'input',
|
|
288
|
+
list: listOf,
|
|
289
|
+
object: (node, properties, path) => {
|
|
290
|
+
const name = `${path.name}${path.suffix}`
|
|
291
|
+
return types.input(
|
|
292
|
+
name,
|
|
293
|
+
JSON.stringify(node),
|
|
294
|
+
path.owner,
|
|
295
|
+
() =>
|
|
296
|
+
new GraphQLInputObjectType({
|
|
297
|
+
name,
|
|
298
|
+
description: describedBy(node),
|
|
299
|
+
fields: fieldsOf(node, properties, path, input),
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
return { output, input }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** An output schema's type, nullable when its JSON Schema accepts `null`. */
|
|
308
|
+
export function outputData(
|
|
309
|
+
schema: object,
|
|
310
|
+
path: Path,
|
|
311
|
+
types: DataTypes,
|
|
312
|
+
): Typed<GraphQLNullableOutputType> {
|
|
313
|
+
const root = jsonSchemaOf(schema, 'output', `${path.owner}: ${path.at}`)
|
|
314
|
+
return dataType(root, path, scopesOf(root, types, false).output)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* An object input's properties as arguments, `flat`; any other input as one
|
|
319
|
+
* `input` argument (D-109).
|
|
320
|
+
*/
|
|
321
|
+
export function inputArgs(
|
|
322
|
+
schema: object,
|
|
323
|
+
path: Path,
|
|
324
|
+
types: DataTypes,
|
|
325
|
+
): { readonly args: GraphQLFieldConfigArgumentMap; readonly flat: boolean } {
|
|
326
|
+
const root = jsonSchemaOf(schema, 'input', `${path.owner}: ${path.at}`)
|
|
327
|
+
const scope = scopesOf(root, types, false).input
|
|
328
|
+
const ref = root['$ref']
|
|
329
|
+
const object = typeof ref === 'string' ? scope.defs[ref.slice(8)] : root
|
|
330
|
+
const properties = isNode(object) ? object['properties'] : undefined
|
|
331
|
+
if (isNode(object) && object['type'] === 'object' && isNode(properties)) {
|
|
332
|
+
const args = propertiesOf(object, properties, path, scope)
|
|
333
|
+
return { args: Object.fromEntries(args), flat: true }
|
|
334
|
+
}
|
|
335
|
+
const { type, nullable } = dataType(root, path, scope)
|
|
336
|
+
const description = describedBy(root)
|
|
337
|
+
return {
|
|
338
|
+
args: {
|
|
339
|
+
input: { type: nullable ? type : new GraphQLNonNull(type), description },
|
|
340
|
+
},
|
|
341
|
+
flat: false,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A json field's type when read, from its schema's output side (D-125);
|
|
347
|
+
* `undefined` when the schema has no JSON Schema export.
|
|
348
|
+
*/
|
|
349
|
+
export function fieldOutput(
|
|
350
|
+
schema: object,
|
|
351
|
+
path: Path,
|
|
352
|
+
types: DataTypes,
|
|
353
|
+
): Typed<GraphQLNullableOutputType> | undefined {
|
|
354
|
+
const root = exportOf(schema, 'output')
|
|
355
|
+
return root && dataType(root, path, scopesOf(root, types, true).output)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A json field's type when written, from its schema's input side. */
|
|
359
|
+
export function fieldInput(
|
|
360
|
+
schema: object,
|
|
361
|
+
path: Path,
|
|
362
|
+
types: DataTypes,
|
|
363
|
+
): Typed<GraphQLNullableInputType> | undefined {
|
|
364
|
+
const root = exportOf(schema, 'input')
|
|
365
|
+
return root && dataType(root, path, scopesOf(root, types, true).input)
|
|
366
|
+
}
|
package/src/depth.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The depth limits (D-107): a document nested deeper than `maxDepth` is
|
|
2
|
+
// refused before it runs. `__schema` and `__type` do not count, since the
|
|
3
|
+
// standard introspection query is about 12 levels deep; they have their own
|
|
4
|
+
// limit, graphql-js's, measured here once per fragment.
|
|
5
|
+
import type {
|
|
6
|
+
ASTVisitor,
|
|
7
|
+
FieldNode,
|
|
8
|
+
SelectionNode,
|
|
9
|
+
SelectionSetNode,
|
|
10
|
+
ValidationContext,
|
|
11
|
+
ValidationRule,
|
|
12
|
+
} from 'graphql'
|
|
13
|
+
import { GraphQLError, Kind } from 'graphql'
|
|
14
|
+
|
|
15
|
+
const introspection = new Set(['__schema', '__type'])
|
|
16
|
+
|
|
17
|
+
/** A field's depth, given the depth of what it selects. */
|
|
18
|
+
type Weigh = (field: FieldNode, below: () => number) => number
|
|
19
|
+
|
|
20
|
+
/** The deepest path under a selection set, each fragment measured once. */
|
|
21
|
+
function measurer(
|
|
22
|
+
context: ValidationContext,
|
|
23
|
+
weigh: Weigh,
|
|
24
|
+
): (set: SelectionSetNode | undefined) => number {
|
|
25
|
+
// One being measured counts 0, which only a cycle meets, and
|
|
26
|
+
// `NoFragmentCyclesRule` refuses a cycle.
|
|
27
|
+
const fragments = new Map<string, number>()
|
|
28
|
+
const depth = (set: SelectionSetNode | undefined): number => {
|
|
29
|
+
let deepest = 0
|
|
30
|
+
for (const selection of set?.selections ?? []) {
|
|
31
|
+
deepest = Math.max(deepest, depthOf(selection))
|
|
32
|
+
}
|
|
33
|
+
return deepest
|
|
34
|
+
}
|
|
35
|
+
const depthOf = (selection: SelectionNode): number => {
|
|
36
|
+
if (selection.kind === Kind.FIELD) {
|
|
37
|
+
return weigh(selection, () => depth(selection.selectionSet))
|
|
38
|
+
}
|
|
39
|
+
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
40
|
+
return depth(selection.selectionSet)
|
|
41
|
+
}
|
|
42
|
+
const name = selection.name.value
|
|
43
|
+
const known = fragments.get(name)
|
|
44
|
+
if (known !== undefined) return known
|
|
45
|
+
fragments.set(name, 0)
|
|
46
|
+
const measured = depth(context.getFragment(name)?.selectionSet)
|
|
47
|
+
fragments.set(name, measured)
|
|
48
|
+
return measured
|
|
49
|
+
}
|
|
50
|
+
return depth
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The validation rule that refuses a document deeper than `maxDepth`. */
|
|
54
|
+
export function depthRule(maxDepth: number): ValidationRule {
|
|
55
|
+
return (context: ValidationContext): ASTVisitor => {
|
|
56
|
+
const depth = measurer(context, (field, below) =>
|
|
57
|
+
introspection.has(field.name.value) ? 0 : 1 + below(),
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
OperationDefinition: (node) => {
|
|
61
|
+
const measured = depth(node.selectionSet)
|
|
62
|
+
if (measured <= maxDepth) return
|
|
63
|
+
context.reportError(
|
|
64
|
+
new GraphQLError(
|
|
65
|
+
`The document is nested ${measured} levels deep; the most is ${maxDepth}.`,
|
|
66
|
+
{ nodes: node },
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The lists graphql-js's `MaxIntrospectionDepthRule` counts, and how many
|
|
75
|
+
// nested ones it refuses. Its own walk visits a fragment once per path, so a
|
|
76
|
+
// fragment spreading the previous one twice costs 2^n visits.
|
|
77
|
+
const lists = new Set(['fields', 'interfaces', 'possibleTypes', 'inputFields'])
|
|
78
|
+
const maxLists = 3
|
|
79
|
+
|
|
80
|
+
/** `MaxIntrospectionDepthRule`, measuring each fragment once. */
|
|
81
|
+
export const introspectionDepthRule: ValidationRule = (context) => {
|
|
82
|
+
const depth = measurer(
|
|
83
|
+
context,
|
|
84
|
+
(field, below) => (lists.has(field.name.value) ? 1 : 0) + below(),
|
|
85
|
+
)
|
|
86
|
+
return {
|
|
87
|
+
Field: (node) => {
|
|
88
|
+
if (!introspection.has(node.name.value)) return undefined
|
|
89
|
+
if (depth(node.selectionSet) < maxLists) return undefined
|
|
90
|
+
context.reportError(
|
|
91
|
+
new GraphQLError('Maximum introspection depth exceeded', {
|
|
92
|
+
nodes: [node],
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
return false
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Errors as D-106 sends them, over GraphQL (D-107): a `PublicError` with its
|
|
2
|
+
// code and details, any other error masked and logged with its id, and
|
|
3
|
+
// graphql-js's own without the values they refused.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { GraphQLError } from 'graphql'
|
|
6
|
+
import type { Logger } from 'ultimabase'
|
|
7
|
+
import { PublicError } from 'ultimabase'
|
|
8
|
+
|
|
9
|
+
type Format = (error: Readonly<GraphQLError | Error>) => GraphQLError | Error
|
|
10
|
+
|
|
11
|
+
/** graphql-js's message without the value it refused, a password perhaps. */
|
|
12
|
+
function unvalued(error: Readonly<GraphQLError>): GraphQLError {
|
|
13
|
+
const { message, nodes, path, extensions } = error
|
|
14
|
+
const reason = message.replace(
|
|
15
|
+
/[,;]? [Ff]ound: [\s\S]*$|(?<= cannot represent [^:]*): [\s\S]*$/,
|
|
16
|
+
'',
|
|
17
|
+
)
|
|
18
|
+
if (reason === message) return error
|
|
19
|
+
const ended = /[.?]$/.test(reason) ? reason : `${reason}.`
|
|
20
|
+
return new GraphQLError(ended, { nodes, path, extensions })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `graphql-http`'s `formatError`: what a client sees of each error. */
|
|
24
|
+
export function formatter(logger: Logger): Format {
|
|
25
|
+
return (error) => {
|
|
26
|
+
// A request `graphql-http` could not read, or a document GraphQL
|
|
27
|
+
// refused: their messages are written for the client.
|
|
28
|
+
if (!(error instanceof GraphQLError)) return error
|
|
29
|
+
const original = error.originalError
|
|
30
|
+
if (original === undefined || original instanceof GraphQLError) {
|
|
31
|
+
return unvalued(error)
|
|
32
|
+
}
|
|
33
|
+
const at = { nodes: error.nodes, path: error.path }
|
|
34
|
+
if (original instanceof PublicError) {
|
|
35
|
+
const { code, details } = original
|
|
36
|
+
return new GraphQLError(original.message, {
|
|
37
|
+
...at,
|
|
38
|
+
extensions: details === undefined ? { code } : { code, details },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
const correlationId = randomUUID()
|
|
42
|
+
logger.error({ correlationId, err: original }, 'A GraphQL field failed.')
|
|
43
|
+
return new GraphQLError('Internal error.', {
|
|
44
|
+
...at,
|
|
45
|
+
extensions: { code: 'INTERNAL', correlationId },
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|