@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -2
  3. package/dist/entries.d.ts +38 -0
  4. package/dist/entries.d.ts.map +1 -0
  5. package/dist/entries.js +47 -0
  6. package/dist/fields.d.ts +77 -0
  7. package/dist/fields.d.ts.map +1 -0
  8. package/dist/fields.js +288 -0
  9. package/dist/functions.d.ts +28 -0
  10. package/dist/functions.d.ts.map +1 -0
  11. package/dist/functions.js +156 -0
  12. package/dist/index.d.ts +39 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +62 -0
  15. package/dist/json-schema.d.ts +8 -0
  16. package/dist/json-schema.d.ts.map +1 -0
  17. package/dist/json-schema.js +44 -0
  18. package/dist/limits.d.ts +6 -0
  19. package/dist/limits.d.ts.map +1 -0
  20. package/dist/limits.js +17 -0
  21. package/dist/live.d.ts +15 -0
  22. package/dist/live.d.ts.map +1 -0
  23. package/dist/live.js +168 -0
  24. package/dist/models.d.ts +8 -0
  25. package/dist/models.d.ts.map +1 -0
  26. package/dist/models.js +136 -0
  27. package/dist/openapi-functions.d.ts +11 -0
  28. package/dist/openapi-functions.d.ts.map +1 -0
  29. package/dist/openapi-functions.js +85 -0
  30. package/dist/openapi-inputs.d.ts +30 -0
  31. package/dist/openapi-inputs.d.ts.map +1 -0
  32. package/dist/openapi-inputs.js +316 -0
  33. package/dist/openapi-models.d.ts +14 -0
  34. package/dist/openapi-models.d.ts.map +1 -0
  35. package/dist/openapi-models.js +280 -0
  36. package/dist/openapi.d.ts +47 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +150 -0
  39. package/dist/params.d.ts +11 -0
  40. package/dist/params.d.ts.map +1 -0
  41. package/dist/params.js +27 -0
  42. package/dist/sse.d.ts +17 -0
  43. package/dist/sse.d.ts.map +1 -0
  44. package/dist/sse.js +63 -0
  45. package/dist/streams.d.ts +73 -0
  46. package/dist/streams.d.ts.map +1 -0
  47. package/dist/streams.js +218 -0
  48. package/dist/values.d.ts +7 -0
  49. package/dist/values.d.ts.map +1 -0
  50. package/dist/values.js +123 -0
  51. package/package.json +47 -3
  52. package/src/entries.ts +82 -0
  53. package/src/fields.ts +391 -0
  54. package/src/functions.ts +205 -0
  55. package/src/index.ts +105 -0
  56. package/src/json-schema.ts +51 -0
  57. package/src/limits.ts +28 -0
  58. package/src/live.ts +223 -0
  59. package/src/models.ts +180 -0
  60. package/src/openapi-functions.ts +127 -0
  61. package/src/openapi-inputs.ts +399 -0
  62. package/src/openapi-models.ts +367 -0
  63. package/src/openapi.ts +204 -0
  64. package/src/params.ts +39 -0
  65. package/src/sse.ts +79 -0
  66. package/src/streams.ts +301 -0
  67. 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
+ }