@ultimabase/rest 0.0.0-stage → 0.1.1
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 +24 -0
- package/README.md +4 -2
- package/dist/entries.d.ts +38 -0
- package/dist/entries.d.ts.map +1 -0
- package/dist/entries.js +47 -0
- package/dist/fields.d.ts +77 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +288 -0
- package/dist/functions.d.ts +28 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +156 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +44 -0
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/live.d.ts +15 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +168 -0
- package/dist/models.d.ts +8 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +136 -0
- package/dist/openapi-functions.d.ts +11 -0
- package/dist/openapi-functions.d.ts.map +1 -0
- package/dist/openapi-functions.js +85 -0
- package/dist/openapi-inputs.d.ts +30 -0
- package/dist/openapi-inputs.d.ts.map +1 -0
- package/dist/openapi-inputs.js +316 -0
- package/dist/openapi-models.d.ts +14 -0
- package/dist/openapi-models.d.ts.map +1 -0
- package/dist/openapi-models.js +280 -0
- package/dist/openapi.d.ts +47 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +150 -0
- package/dist/params.d.ts +11 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +27 -0
- package/dist/sse.d.ts +17 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +63 -0
- package/dist/streams.d.ts +73 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +218 -0
- package/dist/values.d.ts +7 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +123 -0
- package/package.json +47 -3
- package/src/entries.ts +82 -0
- package/src/fields.ts +391 -0
- package/src/functions.ts +205 -0
- package/src/index.ts +105 -0
- package/src/json-schema.ts +51 -0
- package/src/limits.ts +28 -0
- package/src/live.ts +223 -0
- package/src/models.ts +180 -0
- package/src/openapi-functions.ts +127 -0
- package/src/openapi-inputs.ts +399 -0
- package/src/openapi-models.ts +367 -0
- package/src/openapi.ts +204 -0
- package/src/params.ts +39 -0
- package/src/sse.ts +79 -0
- package/src/streams.ts +301 -0
- package/src/values.ts +163 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A function's standard JSON Schema exports (D-45), read as GraphQL reads
|
|
2
|
+
// them: its input's tells a GET's string parameters from its JSON ones, its
|
|
3
|
+
// output's whether it accepts `null`.
|
|
4
|
+
import type { Node } from '@ultimabase/http/internal'
|
|
5
|
+
import { isNode } from '@ultimabase/http/internal'
|
|
6
|
+
|
|
7
|
+
/** The node a `$ref` names among the root's `$defs`, or the node itself. */
|
|
8
|
+
export function resolved(node: Node, root: Node): Node {
|
|
9
|
+
const ref = node['$ref']
|
|
10
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/$defs/')) return node
|
|
11
|
+
const defs = isNode(root['$defs']) ? root['$defs'] : {}
|
|
12
|
+
const found = defs[ref.slice(8)]
|
|
13
|
+
return isNode(found) ? found : node
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A value only ever a string, which a query parameter holds as is. */
|
|
17
|
+
export function isText(node: Node, root: Node): boolean {
|
|
18
|
+
const own = resolved(node, root)
|
|
19
|
+
const { type } = own
|
|
20
|
+
if (type === 'string') return true
|
|
21
|
+
// `['string', 'null']`, a nullable string.
|
|
22
|
+
if (Array.isArray(type)) {
|
|
23
|
+
return (
|
|
24
|
+
type.includes('string') &&
|
|
25
|
+
type.every((each) => each === 'string' || each === 'null')
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
if (typeof own['const'] === 'string') return true
|
|
29
|
+
const values = own['enum']
|
|
30
|
+
if (Array.isArray(values)) {
|
|
31
|
+
return (
|
|
32
|
+
values.length > 0 && values.every((value) => typeof value === 'string')
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
const variants = own['anyOf'] ?? own['oneOf']
|
|
36
|
+
if (!Array.isArray(variants)) return false
|
|
37
|
+
const kept = variants
|
|
38
|
+
.filter(isNode)
|
|
39
|
+
.filter((each) => resolved(each, root)['type'] !== 'null')
|
|
40
|
+
return kept.length > 0 && kept.every((each) => isText(each, root))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A node that accepts `null`, as GraphQL types a nullable output. */
|
|
44
|
+
export function acceptsNull(node: Node): boolean {
|
|
45
|
+
const variants: unknown = node['anyOf'] ?? node['oneOf']
|
|
46
|
+
if (Array.isArray(variants)) {
|
|
47
|
+
return variants.some((each) => isNode(each) && each['type'] === 'null')
|
|
48
|
+
}
|
|
49
|
+
const { type } = node
|
|
50
|
+
return Array.isArray(type) ? type.includes('null') : type === 'null'
|
|
51
|
+
}
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The limits of a request of a caller other than Master (D-107): how deep it
|
|
2
|
+
// nests and, unless it is live (D-154), how many rows it may return.
|
|
3
|
+
import { badInput } from '@ultimabase/http/internal'
|
|
4
|
+
import type { Caller } from 'ultimabase'
|
|
5
|
+
import type { Api } from './entries.ts'
|
|
6
|
+
import type { Shape } from './fields.ts'
|
|
7
|
+
import { depthOf, rowsBelow } from './fields.ts'
|
|
8
|
+
|
|
9
|
+
/** Refuses a request above the limits; `rows` absent checks its depth only. */
|
|
10
|
+
export function bounded(
|
|
11
|
+
api: Api,
|
|
12
|
+
caller: Caller,
|
|
13
|
+
shape: Shape,
|
|
14
|
+
rows?: number,
|
|
15
|
+
): void {
|
|
16
|
+
if (caller.kind === 'master') return
|
|
17
|
+
const depth = depthOf(shape)
|
|
18
|
+
if (depth > api.maxDepth) {
|
|
19
|
+
throw badInput(
|
|
20
|
+
`The request is nested ${depth} levels deep, its orderBy included; the most is ${api.maxDepth}.`,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
if (rows === undefined || rows + rowsBelow(shape, rows) <= api.maxRows) return
|
|
24
|
+
const fewer = api.pagination === 'list' ? 'take' : 'first or last'
|
|
25
|
+
throw badInput(
|
|
26
|
+
`The request asks for more than ${api.maxRows.toLocaleString('en-US')} rows. Ask for fewer with ${fewer}.`,
|
|
27
|
+
)
|
|
28
|
+
}
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// A live model's list and row over Server-Sent Events (D-154): the read's
|
|
2
|
+
// JSON as `event: data`, then what changed as `event: live`, the JSON GraphQL
|
|
3
|
+
// sends under `extensions.live`; in page mode, `event: data` each time. A
|
|
4
|
+
// list first sends the token that resumes it, as `event: resume`. A live
|
|
5
|
+
// function sends its whole result as `event: data` each time it changes.
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { applied, badInput, connection, run } from '@ultimabase/http/internal'
|
|
8
|
+
import type { Caller, Logger, Row } from 'ultimabase'
|
|
9
|
+
import { PublicError } from 'ultimabase'
|
|
10
|
+
import type { Api, Args, Entry } from './entries.ts'
|
|
11
|
+
import type { Shape } from './fields.ts'
|
|
12
|
+
import { readerOf, shaped } from './fields.ts'
|
|
13
|
+
import { bounded } from './limits.ts'
|
|
14
|
+
import { paramsOf } from './params.ts'
|
|
15
|
+
import type { Live, Sink, Write } from './streams.ts'
|
|
16
|
+
import { streamed } from './streams.ts'
|
|
17
|
+
|
|
18
|
+
/** Whether `request` asks for a stream of events. */
|
|
19
|
+
export const wantsEvents = (request: Request): boolean =>
|
|
20
|
+
(request.headers.get('accept') ?? '').includes('text/event-stream')
|
|
21
|
+
|
|
22
|
+
/** The engine, unless `entry`'s model is not live. */
|
|
23
|
+
function liveOf(live: Live | undefined, entry: Entry): Live {
|
|
24
|
+
const { name } = entry.model
|
|
25
|
+
if (live !== undefined && live.realtime.live(name)) return live
|
|
26
|
+
throw badInput(`${name} is not live: list realtime() in its use.`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fromTop = { size: undefined, backward: false }
|
|
30
|
+
const paging = new Set(['take', 'skip', 'cursor'])
|
|
31
|
+
|
|
32
|
+
/** What a live list's messages write, with what it sent so far. */
|
|
33
|
+
function listWriter(
|
|
34
|
+
events: Sink,
|
|
35
|
+
connected: boolean,
|
|
36
|
+
page: boolean,
|
|
37
|
+
counted: boolean,
|
|
38
|
+
shape: Shape,
|
|
39
|
+
): Write {
|
|
40
|
+
const node = (row: Row) => shaped(row, shape)
|
|
41
|
+
let window: readonly Row[] = []
|
|
42
|
+
let hasMore = false
|
|
43
|
+
let count: number | undefined
|
|
44
|
+
let pageInfo = ''
|
|
45
|
+
const infoOf = () => {
|
|
46
|
+
const { pageInfo: info } = connection(window, fromTop, () => null)
|
|
47
|
+
return { ...info, hasNextPage: hasMore }
|
|
48
|
+
}
|
|
49
|
+
const data = (revision: number) => {
|
|
50
|
+
const info = infoOf()
|
|
51
|
+
pageInfo = JSON.stringify(info)
|
|
52
|
+
const { edges } = connection(window, fromTop, node)
|
|
53
|
+
const body = connected
|
|
54
|
+
? { edges, pageInfo: info, ...(counted && { totalCount: count }) }
|
|
55
|
+
: window.map(node)
|
|
56
|
+
events.send('data', body, revision)
|
|
57
|
+
}
|
|
58
|
+
return (message) => {
|
|
59
|
+
const { revision } = message
|
|
60
|
+
hasMore = message.hasMore ?? hasMore
|
|
61
|
+
count = message.totalCount ?? count
|
|
62
|
+
if (message.kind === 'reset' || page) {
|
|
63
|
+
window =
|
|
64
|
+
message.kind === 'reset'
|
|
65
|
+
? message.rows
|
|
66
|
+
: applied(window, message.changes, (row) => row)
|
|
67
|
+
return data(revision)
|
|
68
|
+
}
|
|
69
|
+
const { changes } = message
|
|
70
|
+
window = applied(window, changes, ({ id }) => ({ id }))
|
|
71
|
+
const live: { [key: string]: unknown } = {
|
|
72
|
+
revision,
|
|
73
|
+
changes: changes.map((change) => {
|
|
74
|
+
if ('remove' in change) return change
|
|
75
|
+
const [edge] = connection([change.upsert], fromTop, node).edges
|
|
76
|
+
return {
|
|
77
|
+
upsert: connected ? edge : node(change.upsert),
|
|
78
|
+
after: change.after,
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
if (counted && message.totalCount !== undefined) {
|
|
83
|
+
live['totalCount'] = message.totalCount
|
|
84
|
+
}
|
|
85
|
+
const info = infoOf()
|
|
86
|
+
if (connected && JSON.stringify(info) !== pageInfo) {
|
|
87
|
+
pageInfo = JSON.stringify(info)
|
|
88
|
+
live['pageInfo'] = info
|
|
89
|
+
}
|
|
90
|
+
events.send('live', live, revision)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `GET /api/posts` with `Accept: text/event-stream`: a live window. */
|
|
95
|
+
export async function liveList(
|
|
96
|
+
api: Api,
|
|
97
|
+
live: Live | undefined,
|
|
98
|
+
entry: Entry,
|
|
99
|
+
caller: Caller,
|
|
100
|
+
request: Request,
|
|
101
|
+
): Promise<Response> {
|
|
102
|
+
const followed = liveOf(live, entry)
|
|
103
|
+
const connected = api.pagination === 'connection'
|
|
104
|
+
const arg = connected ? 'first' : 'take'
|
|
105
|
+
const params = paramsOf(request.url, {
|
|
106
|
+
json: [
|
|
107
|
+
'where',
|
|
108
|
+
'orderBy',
|
|
109
|
+
'select',
|
|
110
|
+
'include',
|
|
111
|
+
arg,
|
|
112
|
+
...(connected ? ['totalCount'] : []),
|
|
113
|
+
],
|
|
114
|
+
text: ['mode', 'resume'],
|
|
115
|
+
})
|
|
116
|
+
const { totalCount, mode, resume, ...args } = params
|
|
117
|
+
if (totalCount !== undefined && typeof totalCount !== 'boolean') {
|
|
118
|
+
throw badInput('totalCount must be true or false.')
|
|
119
|
+
}
|
|
120
|
+
if (mode !== undefined && mode !== 'page') {
|
|
121
|
+
throw badInput('mode must be page.')
|
|
122
|
+
}
|
|
123
|
+
const { name } = entry.model
|
|
124
|
+
const read = await run(async () =>
|
|
125
|
+
readerOf(api, caller.kind === 'master').list(entry, args, ''),
|
|
126
|
+
)
|
|
127
|
+
const asked = args[arg]
|
|
128
|
+
const rows = typeof asked === 'number' ? asked : entry.take
|
|
129
|
+
const page = mode === 'page'
|
|
130
|
+
if (page && rows > entry.maxTake) {
|
|
131
|
+
throw badInput(
|
|
132
|
+
`${arg} is at most ${entry.maxTake} on ${name} in page mode.`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
// A live list is bounded by maxLive, not by maxRows (D-154).
|
|
136
|
+
bounded(api, caller, read.shape)
|
|
137
|
+
// The window starts at the top of its order and holds `first` rows.
|
|
138
|
+
const listed: Args = {
|
|
139
|
+
...Object.fromEntries(
|
|
140
|
+
Object.entries(read.read).filter(([key]) => !paging.has(key)),
|
|
141
|
+
),
|
|
142
|
+
first: rows,
|
|
143
|
+
totalCount: totalCount === true,
|
|
144
|
+
}
|
|
145
|
+
return streamed(
|
|
146
|
+
followed,
|
|
147
|
+
request,
|
|
148
|
+
read.shape,
|
|
149
|
+
async (viewer, send) => viewer.list(name, listed, send),
|
|
150
|
+
{
|
|
151
|
+
write: (events) =>
|
|
152
|
+
listWriter(events, connected, page, totalCount === true, read.shape),
|
|
153
|
+
signature: JSON.stringify([name, page, { ...listed, first: undefined }]),
|
|
154
|
+
first: rows,
|
|
155
|
+
resume: typeof resume === 'string' ? resume : undefined,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `GET /api/posts/:id` with `Accept: text/event-stream`: a live row. */
|
|
161
|
+
export async function liveRow(
|
|
162
|
+
api: Api,
|
|
163
|
+
live: Live | undefined,
|
|
164
|
+
entry: Entry,
|
|
165
|
+
caller: Caller,
|
|
166
|
+
request: Request,
|
|
167
|
+
id: string,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
const followed = liveOf(live, entry)
|
|
170
|
+
const params = paramsOf(request.url, {
|
|
171
|
+
json: ['select', 'include'],
|
|
172
|
+
// A live row is not resumed: it starts afresh.
|
|
173
|
+
text: ['resume'],
|
|
174
|
+
})
|
|
175
|
+
const { resume: _, ...fields } = params
|
|
176
|
+
const read = await run(async () =>
|
|
177
|
+
readerOf(api, caller.kind === 'master').fields(entry, fields, ''),
|
|
178
|
+
)
|
|
179
|
+
bounded(api, caller, read.shape)
|
|
180
|
+
const args = { ...read.read, where: { id } }
|
|
181
|
+
return streamed(followed, request, read.shape, async (viewer, send) =>
|
|
182
|
+
viewer.row(entry.model.name, args, send),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A live function's error as its client gets it: public as is, else masked and logged. */
|
|
187
|
+
function failure(error: unknown, logger: Logger): unknown {
|
|
188
|
+
if (error instanceof PublicError) {
|
|
189
|
+
const { code, message, details } = error
|
|
190
|
+
return details === undefined
|
|
191
|
+
? { code, message }
|
|
192
|
+
: { code, message, details }
|
|
193
|
+
}
|
|
194
|
+
const correlationId = randomUUID()
|
|
195
|
+
logger.error({ correlationId, err: error }, 'A live function failed.')
|
|
196
|
+
return { code: 'INTERNAL', message: 'Internal error.', correlationId }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const noShape: Shape = { fields: [] }
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `GET /api/functions/:name` with `Accept: text/event-stream`: the result
|
|
203
|
+
* `compute` gives as the subscriber, again each time it changes.
|
|
204
|
+
*/
|
|
205
|
+
export async function liveValue(
|
|
206
|
+
live: Live | undefined,
|
|
207
|
+
owner: string,
|
|
208
|
+
request: Request,
|
|
209
|
+
compute: (caller: Caller) => Promise<unknown>,
|
|
210
|
+
): Promise<Response> {
|
|
211
|
+
if (live === undefined || !live.realtime.liveFunction(owner)) {
|
|
212
|
+
throw badInput(`${owner} is not live: list realtime() in its use.`)
|
|
213
|
+
}
|
|
214
|
+
const { logger } = live
|
|
215
|
+
return streamed(live, request, noShape, async (viewer, send) =>
|
|
216
|
+
viewer.value(owner, compute, (message) => {
|
|
217
|
+
if (message.kind !== 'closed' || message.error === undefined) {
|
|
218
|
+
return send(message)
|
|
219
|
+
}
|
|
220
|
+
send({ kind: 'closed', error: failure(message.error, logger) })
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// A model's routes (D-129): its list, its row, and its four writes, an
|
|
2
|
+
// upsert included (D-142), each one kernel query run as the request's
|
|
3
|
+
// caller, answered with the JSON the matching GraphQL field returns.
|
|
4
|
+
import type { Routes, StandardSchema } from '@ultimabase/http'
|
|
5
|
+
import { validate } from '@ultimabase/http'
|
|
6
|
+
import { badInput, connection, pageArgs, run } from '@ultimabase/http/internal'
|
|
7
|
+
import type { Caller, Row } from 'ultimabase'
|
|
8
|
+
import { InvalidQuery, PublicError } from 'ultimabase'
|
|
9
|
+
import type { Api, Args, Entry } from './entries.ts'
|
|
10
|
+
import type { Read } from './fields.ts'
|
|
11
|
+
import { defaultShape, filterOf, readerOf, shaped } from './fields.ts'
|
|
12
|
+
import { bounded } from './limits.ts'
|
|
13
|
+
import type { Live } from './streams.ts'
|
|
14
|
+
import { liveList, liveRow, wantsEvents } from './live.ts'
|
|
15
|
+
import type { Accepted } from './params.ts'
|
|
16
|
+
import { paramsOf } from './params.ts'
|
|
17
|
+
import { dataOf, isArgs, whereOf } from './values.ts'
|
|
18
|
+
|
|
19
|
+
/** Any JSON body: the kernel checks the data (D-129). */
|
|
20
|
+
export const anyBody: StandardSchema = {
|
|
21
|
+
'~standard': { validate: (value) => ({ value }) },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const fieldParams: Accepted = { json: ['select', 'include'], text: [] }
|
|
25
|
+
|
|
26
|
+
/** The kernel read of one row's `select` or `include`. */
|
|
27
|
+
function oneRead(api: Api, entry: Entry, caller: Caller, url: string): Read {
|
|
28
|
+
const params = paramsOf(url, fieldParams)
|
|
29
|
+
const read = readerOf(api, caller.kind === 'master').fields(entry, params, '')
|
|
30
|
+
bounded(api, caller, read.shape, 1)
|
|
31
|
+
return read
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `GET /api/posts`: a connection, or a list with `pagination: 'list'`. */
|
|
35
|
+
async function list(
|
|
36
|
+
api: Api,
|
|
37
|
+
entry: Entry,
|
|
38
|
+
caller: Caller,
|
|
39
|
+
url: string,
|
|
40
|
+
): Promise<unknown> {
|
|
41
|
+
const counts = api.pagination === 'connection'
|
|
42
|
+
const params = paramsOf(url, {
|
|
43
|
+
json: [
|
|
44
|
+
'where',
|
|
45
|
+
'orderBy',
|
|
46
|
+
'select',
|
|
47
|
+
'include',
|
|
48
|
+
...pageArgs(api.pagination).filter(
|
|
49
|
+
(arg) => arg !== 'after' && arg !== 'before',
|
|
50
|
+
),
|
|
51
|
+
...(counts ? ['totalCount'] : []),
|
|
52
|
+
],
|
|
53
|
+
text: counts ? ['after', 'before'] : [],
|
|
54
|
+
})
|
|
55
|
+
const { totalCount, ...args } = params
|
|
56
|
+
if (totalCount !== undefined && typeof totalCount !== 'boolean') {
|
|
57
|
+
throw badInput('totalCount must be true or false.')
|
|
58
|
+
}
|
|
59
|
+
const read = readerOf(api, caller.kind === 'master').list(entry, args, '')
|
|
60
|
+
// A count reads the rows it counts, however few the page returns.
|
|
61
|
+
const size = totalCount === true ? Math.max(read.size, entry.take) : read.size
|
|
62
|
+
bounded(api, caller, read.shape, size)
|
|
63
|
+
const { queries } = entry.exposed
|
|
64
|
+
const rows = await queries.findMany(read.read, { as: caller })
|
|
65
|
+
const node = (row: Row) => shaped(row, read.shape)
|
|
66
|
+
if (read.relay === undefined) return rows.map(node)
|
|
67
|
+
const page = connection(rows, read.relay, node)
|
|
68
|
+
if (totalCount !== true) return page
|
|
69
|
+
const where = filterOf(api, entry, args['where'], 'where')
|
|
70
|
+
return { ...page, totalCount: await queries.count(where, { as: caller }) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const notFound = (entry: Entry): Error =>
|
|
74
|
+
new PublicError('NOT_FOUND', `No ${entry.model.name} matches.`)
|
|
75
|
+
|
|
76
|
+
const written = (api: Api, entry: Entry, body: unknown): unknown =>
|
|
77
|
+
isArgs(body) ? dataOf(api, entry.model.name, body, 'data') : body
|
|
78
|
+
|
|
79
|
+
const upsertKeys = new Set(['where', 'create', 'update'])
|
|
80
|
+
|
|
81
|
+
/** An upsert's body, its dates read; the kernel checks the rest (D-142). */
|
|
82
|
+
function upserting(api: Api, entry: Entry, body: unknown): Args {
|
|
83
|
+
const { name } = entry.model
|
|
84
|
+
if (!isArgs(body))
|
|
85
|
+
throw new InvalidQuery('body', 'The body must be an object.')
|
|
86
|
+
const read: { [key: string]: unknown } = {}
|
|
87
|
+
for (const [key, value] of Object.entries(body)) {
|
|
88
|
+
if (!upsertKeys.has(key)) {
|
|
89
|
+
throw new InvalidQuery(key, `Unknown key '${key}' on ${name}.`)
|
|
90
|
+
}
|
|
91
|
+
if (!isArgs(value)) read[key] = value
|
|
92
|
+
else if (key === 'where') read[key] = whereOf(api, name, value, key)
|
|
93
|
+
else read[key] = dataOf(api, name, value, key)
|
|
94
|
+
}
|
|
95
|
+
return read
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Each route of a visible model, under `base`; its reads live with `live`. */
|
|
99
|
+
export function modelRoutes(
|
|
100
|
+
app: Routes,
|
|
101
|
+
api: Api,
|
|
102
|
+
entry: Entry,
|
|
103
|
+
base: string,
|
|
104
|
+
live: Live | undefined,
|
|
105
|
+
): void {
|
|
106
|
+
const path = `${base}/${entry.plural}`
|
|
107
|
+
const { queries } = entry.exposed
|
|
108
|
+
app.get(path, async (c) => {
|
|
109
|
+
const { caller } = c.var
|
|
110
|
+
if (wantsEvents(c.req.raw)) {
|
|
111
|
+
return liveList(api, live, entry, caller, c.req.raw)
|
|
112
|
+
}
|
|
113
|
+
return c.json(await run(() => list(api, entry, caller, c.req.url)))
|
|
114
|
+
})
|
|
115
|
+
/** The row `query` returns, shaped as `url` asks. */
|
|
116
|
+
const one = async (
|
|
117
|
+
caller: Caller,
|
|
118
|
+
url: string,
|
|
119
|
+
query: (read: Args, as: { as: Caller }) => Promise<Row | null>,
|
|
120
|
+
): Promise<unknown> => {
|
|
121
|
+
const { row, shape } = await run(async () => {
|
|
122
|
+
const fields = oneRead(api, entry, caller, url)
|
|
123
|
+
return { row: await query(fields.read, { as: caller }), ...fields }
|
|
124
|
+
})
|
|
125
|
+
if (row === null) throw notFound(entry)
|
|
126
|
+
return shaped(row, shape)
|
|
127
|
+
}
|
|
128
|
+
app.get(`${path}/:id`, async (c) => {
|
|
129
|
+
const { caller } = c.var
|
|
130
|
+
const id = c.req.param('id')
|
|
131
|
+
if (wantsEvents(c.req.raw)) {
|
|
132
|
+
return liveRow(api, live, entry, caller, c.req.raw, id)
|
|
133
|
+
}
|
|
134
|
+
return c.json(
|
|
135
|
+
await one(caller, c.req.url, async (read, as) =>
|
|
136
|
+
queries.findUnique({ ...read, where: { id } }, as),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
app.post(path, validate('json', anyBody), async (c) => {
|
|
141
|
+
const body: unknown = c.req.valid('json')
|
|
142
|
+
const row = await one(c.var.caller, c.req.url, async (read, as) =>
|
|
143
|
+
queries.create({ ...read, data: written(api, entry, body) }, as),
|
|
144
|
+
)
|
|
145
|
+
return c.json(row, 201)
|
|
146
|
+
})
|
|
147
|
+
// 201 when it created the row, 200 when it updated it (D-142).
|
|
148
|
+
app.put(path, validate('json', anyBody), async (c) => {
|
|
149
|
+
const body: unknown = c.req.valid('json')
|
|
150
|
+
const { caller } = c.var
|
|
151
|
+
const { row, created, shape } = await run(async () => {
|
|
152
|
+
const fields = oneRead(api, entry, caller, c.req.url)
|
|
153
|
+
const args = { ...fields.read, ...upserting(api, entry, body) }
|
|
154
|
+
const upserted = await entry.exposed.upsert(args, { as: caller })
|
|
155
|
+
return { ...upserted, shape: fields.shape }
|
|
156
|
+
})
|
|
157
|
+
return c.json(shaped(row, shape), created ? 201 : 200)
|
|
158
|
+
})
|
|
159
|
+
app.patch(`${path}/:id`, validate('json', anyBody), async (c) => {
|
|
160
|
+
const body: unknown = c.req.valid('json')
|
|
161
|
+
const where = { id: c.req.param('id') }
|
|
162
|
+
return c.json(
|
|
163
|
+
await one(c.var.caller, c.req.url, async (read, as) =>
|
|
164
|
+
queries.update({ ...read, where, data: written(api, entry, body) }, as),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
app.delete(`${path}/:id`, async (c) => {
|
|
169
|
+
const { caller } = c.var
|
|
170
|
+
const row = await run(async () => {
|
|
171
|
+
paramsOf(c.req.url, { json: [], text: [] })
|
|
172
|
+
return queries.delete(
|
|
173
|
+
{ where: { id: c.req.param('id') } },
|
|
174
|
+
{ as: caller },
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
// As it was, without its relations (D-102).
|
|
178
|
+
return c.json(shaped(row, defaultShape(entry)))
|
|
179
|
+
})
|
|
180
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The functions' part of the document (D-144): each one's route, its
|
|
2
|
+
// parameters or body, and its output, as GraphQL names their types.
|
|
3
|
+
import type { Node } from '@ultimabase/http/internal'
|
|
4
|
+
import { isNode, jsonSchemaOf } from '@ultimabase/http/internal'
|
|
5
|
+
import type { ExposedOutput, ExposedType } from 'ultimabase'
|
|
6
|
+
import type { Routed } from './functions.ts'
|
|
7
|
+
import { getParams } from './functions.ts'
|
|
8
|
+
import type { Doc } from './openapi-inputs.ts'
|
|
9
|
+
import { rowOf } from './openapi-models.ts'
|
|
10
|
+
import type { Schema } from './openapi.ts'
|
|
11
|
+
import {
|
|
12
|
+
arrayOf,
|
|
13
|
+
describe,
|
|
14
|
+
failed,
|
|
15
|
+
hoisted,
|
|
16
|
+
json,
|
|
17
|
+
objectOf,
|
|
18
|
+
orNull,
|
|
19
|
+
} from './openapi.ts'
|
|
20
|
+
|
|
21
|
+
const upper = (name: string): string =>
|
|
22
|
+
name.charAt(0).toUpperCase() + name.slice(1)
|
|
23
|
+
|
|
24
|
+
const prefix = '#/components/schemas/'
|
|
25
|
+
|
|
26
|
+
/** A schema, or the component it refers to. */
|
|
27
|
+
function resolve(doc: Doc, schema: Schema): Schema {
|
|
28
|
+
const ref = schema['$ref']
|
|
29
|
+
if (typeof ref !== 'string' || !ref.startsWith(prefix)) return schema
|
|
30
|
+
return doc.components.schemas[ref.slice(prefix.length)] ?? {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A `defineType`, each of its fields always given (D-109). */
|
|
34
|
+
function typeOf(doc: Doc, type: ExposedType, owner: string): Schema {
|
|
35
|
+
return doc.components.ref(type.name, () =>
|
|
36
|
+
describe(
|
|
37
|
+
objectOf(
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
[...type.fields].map(([name, field]) => [
|
|
40
|
+
name,
|
|
41
|
+
outputOf(doc, field, owner, `${type.name}${upper(name)}`),
|
|
42
|
+
]),
|
|
43
|
+
),
|
|
44
|
+
[...type.fields.keys()],
|
|
45
|
+
),
|
|
46
|
+
type.description,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A model, a `defineType` or a schema, held as `form` says (D-109). */
|
|
52
|
+
export function outputOf(
|
|
53
|
+
doc: Doc,
|
|
54
|
+
output: ExposedOutput,
|
|
55
|
+
owner: string,
|
|
56
|
+
root: string,
|
|
57
|
+
): Schema {
|
|
58
|
+
if (output.kind === 'schema') {
|
|
59
|
+
const node = jsonSchemaOf(output.schema, 'output', `${owner}: output`)
|
|
60
|
+
return hoisted(doc.components, node, 'output', owner, root)
|
|
61
|
+
}
|
|
62
|
+
const one =
|
|
63
|
+
output.kind === 'model'
|
|
64
|
+
? rowOf(doc, output.name)
|
|
65
|
+
: typeOf(doc, output.type, owner)
|
|
66
|
+
if (output.form === 'list') return arrayOf(one)
|
|
67
|
+
return output.form === 'nullable' ? orNull(one) : one
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A GET's parameters, as `getParams` reads them. */
|
|
71
|
+
function paramsOf(doc: Doc, input: Node, schema: Schema): readonly Schema[] {
|
|
72
|
+
const { accepted, flat } = getParams(input)
|
|
73
|
+
if (!flat) {
|
|
74
|
+
const as = accepted.text.length > 0 ? { schema } : { content: json(schema) }
|
|
75
|
+
return [{ name: 'input', in: 'query', required: true, ...as }]
|
|
76
|
+
}
|
|
77
|
+
const object = resolve(doc, schema)
|
|
78
|
+
const properties = isNode(object['properties']) ? object['properties'] : {}
|
|
79
|
+
const required = Array.isArray(object['required']) ? object['required'] : []
|
|
80
|
+
return Object.entries(properties).map(([name, property]) => ({
|
|
81
|
+
name,
|
|
82
|
+
in: 'query',
|
|
83
|
+
...(required.includes(name) && { required: true }),
|
|
84
|
+
...(accepted.text.includes(name)
|
|
85
|
+
? { schema: property }
|
|
86
|
+
: { content: json(isNode(property) ? property : {}) }),
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `/functions/{name}`, a query over GET, a mutation over POST (D-109). */
|
|
91
|
+
export function functionPaths(
|
|
92
|
+
doc: Doc,
|
|
93
|
+
functions: readonly Routed[],
|
|
94
|
+
): { [path: string]: Schema } {
|
|
95
|
+
const paths: { [path: string]: Schema } = {}
|
|
96
|
+
for (const { fn, owner, name, input } of functions) {
|
|
97
|
+
const base = upper(name)
|
|
98
|
+
const given =
|
|
99
|
+
input === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: hoisted(doc.components, input, 'input', owner, `${base}Input`)
|
|
102
|
+
const output =
|
|
103
|
+
fn.output === undefined
|
|
104
|
+
? { const: true }
|
|
105
|
+
: outputOf(doc, fn.output, owner, `${base}Output`)
|
|
106
|
+
const operation = {
|
|
107
|
+
operationId: doc.components.operation(name, owner, 'name'),
|
|
108
|
+
tags: ['functions'],
|
|
109
|
+
...(fn.description !== undefined && { description: fn.description }),
|
|
110
|
+
...(fn.query && given !== undefined && input !== undefined
|
|
111
|
+
? { parameters: paramsOf(doc, input, given) }
|
|
112
|
+
: {}),
|
|
113
|
+
...(!fn.query &&
|
|
114
|
+
given !== undefined && {
|
|
115
|
+
requestBody: { required: true, content: json(given) },
|
|
116
|
+
}),
|
|
117
|
+
responses: {
|
|
118
|
+
200: { description: `What ${name} returns.`, content: json(output) },
|
|
119
|
+
...failed,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
paths[`/functions/${name}`] = fn.query
|
|
123
|
+
? { get: operation }
|
|
124
|
+
: { post: operation }
|
|
125
|
+
}
|
|
126
|
+
return paths
|
|
127
|
+
}
|