@platformatic/sql-openapi 3.4.1 → 3.5.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/lib/shared.js CHANGED
@@ -1,8 +1,7 @@
1
- 'use strict'
1
+ import { mapSQLTypeToOpenAPIType } from '@platformatic/sql-json-schema-mapper'
2
+ import { buildCursorUtils } from './cursor.js'
2
3
 
3
- const { mapSQLTypeToOpenAPIType } = require('@platformatic/sql-json-schema-mapper')
4
-
5
- function generateArgs (entity, ignore) {
4
+ export function generateArgs (entity, ignore) {
6
5
  const sortedEntityFields = Object.keys(entity.fields).sort()
7
6
 
8
7
  const whereArgs = sortedEntityFields.reduce((acc, name) => {
@@ -18,7 +17,7 @@ function generateArgs (entity, ignore) {
18
17
  acc[key] = { type: mapSQLTypeToOpenAPIType(field.sqlType) }
19
18
  }
20
19
  } else {
21
- for (const modifier of ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like']) {
20
+ for (const modifier of ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike']) {
22
21
  const key = baseKey + modifier
23
22
  acc[key] = { type: mapSQLTypeToOpenAPIType(field.sqlType), enum: field.enum }
24
23
  }
@@ -49,9 +48,9 @@ function generateArgs (entity, ignore) {
49
48
  'where.or': {
50
49
  type: 'array',
51
50
  items: {
52
- type: 'string',
53
- },
54
- },
51
+ type: 'string'
52
+ }
53
+ }
55
54
  }
56
55
 
57
56
  Object.assign(whereArgs, whereOrArrayArgs)
@@ -59,111 +58,161 @@ function generateArgs (entity, ignore) {
59
58
  return { whereArgs, orderByArgs }
60
59
  }
61
60
 
62
- module.exports.generateArgs = generateArgs
63
-
64
- function rootEntityRoutes (app, entity, whereArgs, orderByArgs, entityLinks, entitySchema, fields, entitySchemaInput, ignoreRoutes) {
61
+ export function rootEntityRoutes (
62
+ app,
63
+ entity,
64
+ whereArgs,
65
+ orderByArgs,
66
+ entityLinks,
67
+ entitySchema,
68
+ fields,
69
+ entitySchemaInput,
70
+ ignoreRoutes
71
+ ) {
65
72
  const ignoredGETRoute = ignoreRoutes.find(ignoreRoutes => {
66
73
  return ignoreRoutes.path === app.prefix && ignoreRoutes.method === 'GET'
67
74
  })
68
75
 
69
76
  if (!ignoredGETRoute) {
70
- app.get('/', {
71
- schema: {
72
- operationId: 'get' + capitalize(entity.pluralName),
73
- summary: `Get ${entity.pluralName}.`,
74
- description: `Fetch ${entity.pluralName} from the database.`,
75
- tags: [entity.table],
76
- querystring: {
77
- type: 'object',
78
- properties: {
79
- limit: { type: 'integer', description: 'Limit will be applied by default if not passed. If the provided value exceeds the maximum allowed value a validation error will be thrown' },
80
- offset: { type: 'integer' },
81
- totalCount: { type: 'boolean', default: false },
82
- fields,
83
- ...whereArgs,
84
- ...orderByArgs,
85
- },
86
- additionalProperties: false,
87
- },
88
- response: {
89
- 200: {
90
- type: 'array',
91
- items: entitySchema,
77
+ const { buildCursorHeaders, transformQueryToCursor } = buildCursorUtils(app, entity)
78
+
79
+ app.get(
80
+ '/',
81
+ {
82
+ schema: {
83
+ operationId: 'get' + capitalize(entity.pluralName),
84
+ summary: `Get ${entity.pluralName}.`,
85
+ description: `Fetch ${entity.pluralName} from the database.`,
86
+ tags: [entity.table],
87
+ querystring: {
88
+ type: 'object',
89
+ properties: {
90
+ limit: {
91
+ type: 'integer',
92
+ description:
93
+ 'Limit will be applied by default if not passed. If the provided value exceeds the maximum allowed value a validation error will be thrown'
94
+ },
95
+ offset: { type: 'integer' },
96
+ totalCount: { type: 'boolean', default: false },
97
+ cursor: {
98
+ type: 'boolean',
99
+ default: false,
100
+ description: 'Include cursor headers in response. Cursor keys built from orderBy clause'
101
+ },
102
+ startAfter: {
103
+ type: 'string',
104
+ description: 'Cursor for forward pagination. List objects after this cursor position',
105
+ format: 'byte'
106
+ },
107
+ endBefore: {
108
+ type: 'string',
109
+ description: 'Cursor for backward pagination. List objects before this cursor position',
110
+ format: 'byte'
111
+ },
112
+ fields,
113
+ ...whereArgs,
114
+ ...orderByArgs
115
+ },
116
+ additionalProperties: false
92
117
  },
93
- },
118
+ response: {
119
+ 200: {
120
+ type: 'array',
121
+ items: entitySchema
122
+ }
123
+ }
124
+ }
94
125
  },
95
- }, async function (request, reply) {
96
- const query = request.query
97
- const { limit, offset, fields } = query
98
- const queryKeys = Object.keys(query)
99
- const where = {}
100
- const orderBy = []
101
-
102
- for (let i = 0; i < queryKeys.length; i++) {
103
- const key = queryKeys[i]
104
- if (key.startsWith('where.or')) {
105
- const orParam = query[key][0]
106
- // NOTE: Remove the first and last character which are the brackets
107
- // each or condition is separated by a pipe '|'
108
- // the conditions inside the or statement are the same as it would normally be in the where statement
109
- // except that the field name is not prefixed with 'where.'
110
- // e.g. where.or=(name.eq=foo|name.eq=bar)
111
- // e.g. where.or=(name.eq=foo|name.eq=bar|name.eq=baz)
112
- //
113
- // Also, the or statement supports in and nin operators
114
- // e.g. where.or=(name.in=foo,bar|name.eq=baz)
115
- const parsed = orParam.substring(1, orParam.length - 1).split('|').map((v) => v.split('=')).reduce((acc, [k, v]) => {
116
- const [field, modifier] = k.split('.')
126
+ async function (request, reply) {
127
+ const query = request.query
128
+ const { limit, offset, fields, startAfter, endBefore } = query
129
+ const queryKeys = Object.keys(query)
130
+ const where = {}
131
+ const orderBy = []
132
+
133
+ for (let i = 0; i < queryKeys.length; i++) {
134
+ const key = queryKeys[i]
135
+ if (key.startsWith('where.or')) {
136
+ const orParam = query[key][0]
137
+ // NOTE: Remove the first and last character which are the brackets
138
+ // each or condition is separated by a pipe '|'
139
+ // the conditions inside the or statement are the same as it would normally be in the where statement
140
+ // except that the field name is not prefixed with 'where.'
141
+ // e.g. where.or=(name.eq=foo|name.eq=bar)
142
+ // e.g. where.or=(name.eq=foo|name.eq=bar|name.eq=baz)
143
+ //
144
+ // Also, the or statement supports in and nin operators
145
+ // e.g. where.or=(name.in=foo,bar|name.eq=baz)
146
+ const parsed = orParam
147
+ .substring(1, orParam.length - 1)
148
+ .split('|')
149
+ .map(v => v.split('='))
150
+ .reduce((acc, [k, v]) => {
151
+ const [field, modifier] = k.split('.')
152
+ if (modifier === 'in' || modifier === 'nin') {
153
+ // TODO handle escaping of ,
154
+ v = v.split(',')
155
+ /* istanbul ignore next */
156
+ if (mapSQLTypeToOpenAPIType(entity.camelCasedFields[field].sqlType) === 'integer') {
157
+ v = v.map(v => parseInt(v))
158
+ }
159
+ }
160
+ acc.push({ [field]: { [modifier]: parseNullableValue(field, v) } })
161
+ return acc
162
+ }, [])
163
+ where.or = parsed
164
+ continue
165
+ }
166
+
167
+ if (key.startsWith('where.')) {
168
+ const [, field, modifier] = key.split('.')
169
+ where[field] ||= {}
170
+ let value = query[key]
117
171
  if (modifier === 'in' || modifier === 'nin') {
118
172
  // TODO handle escaping of ,
119
- v = v.split(',')
120
- /* istanbul ignore next */
173
+ value = query[key].split(',')
121
174
  if (mapSQLTypeToOpenAPIType(entity.camelCasedFields[field].sqlType) === 'integer') {
122
- v = v.map((v) => parseInt(v))
175
+ value = value.map(v => parseInt(v))
123
176
  }
124
177
  }
125
- acc.push({ [field]: { [modifier]: v } })
126
- return acc
127
- }, [])
128
- where.or = parsed
129
- continue
130
- }
131
178
 
132
- if (key.startsWith('where.')) {
133
- const [, field, modifier] = key.split('.')
134
- where[field] ||= {}
135
- let value = query[key]
136
- if (modifier === 'in' || modifier === 'nin') {
137
- // TODO handle escaping of ,
138
- value = query[key].split(',')
139
- if (mapSQLTypeToOpenAPIType(entity.camelCasedFields[field].sqlType) === 'integer') {
140
- value = value.map((v) => parseInt(v))
141
- }
179
+ where[field][modifier] = parseNullableValue(field, value)
180
+ } else if (key.startsWith('orderby.')) {
181
+ const [, field] = key.split('.')
182
+ orderBy[field] ||= {}
183
+ orderBy.push({ field, direction: query[key] })
142
184
  }
143
- where[field][modifier] = value
144
- } else if (key.startsWith('orderby.')) {
145
- const [, field] = key.split('.')
146
- orderBy[field] ||= {}
147
- orderBy.push({ field, direction: query[key] })
148
185
  }
149
- }
150
186
 
151
- const ctx = { app: this, reply }
152
- const res = await entity.find({ limit, offset, fields, orderBy, where, ctx })
187
+ const ctx = { app: this, reply }
188
+ const { cursor, nextPage } = transformQueryToCursor({ startAfter, endBefore })
189
+ const res = await entity.find({ limit, offset, fields, orderBy, where, ctx, cursor, nextPage })
153
190
 
154
- // X-Total-Count header
155
- if (query.totalCount) {
156
- let totalCount
157
- if ((((offset ?? 0) === 0) || (res.length > 0)) && ((limit !== undefined) && (res.length < limit))) {
158
- totalCount = (offset ?? 0) + res.length
159
- } else {
160
- totalCount = await entity.count({ where, ctx })
191
+ // X-Total-Count header
192
+ if (query.totalCount) {
193
+ let totalCount
194
+ if (((offset ?? 0) === 0 || res.length > 0) && limit !== undefined && res.length < limit) {
195
+ totalCount = (offset ?? 0) + res.length
196
+ } else {
197
+ totalCount = await entity.count({ where, ctx })
198
+ }
199
+ reply.header('X-Total-Count', totalCount)
161
200
  }
162
- reply.header('X-Total-Count', totalCount)
163
- }
164
201
 
165
- return res
166
- })
202
+ // cursor headers
203
+ if ((query.cursor || startAfter || endBefore) && res.length > 0) {
204
+ const { startAfter, endBefore } = buildCursorHeaders({
205
+ findResult: res,
206
+ orderBy,
207
+ primaryKeys: entity.primaryKeys
208
+ })
209
+ reply.header('X-Start-After', startAfter)
210
+ reply.header('X-End-Before', endBefore)
211
+ }
212
+
213
+ return res
214
+ }
215
+ )
167
216
  }
168
217
 
169
218
  const ignoredPOSTRoute = ignoreRoutes.find(ignoreRoute => {
@@ -171,26 +220,68 @@ function rootEntityRoutes (app, entity, whereArgs, orderByArgs, entityLinks, ent
171
220
  })
172
221
 
173
222
  if (!ignoredPOSTRoute) {
174
- app.post('/', {
175
- schema: {
176
- operationId: 'create' + capitalize(entity.singularName),
177
- summary: `Create ${entity.singularName}.`,
178
- description: `Add new ${entity.singularName} to the database.`,
179
- body: entitySchemaInput,
180
- tags: [entity.table],
181
- response: {
182
- 200: entitySchema,
223
+ app.post(
224
+ '/',
225
+ {
226
+ schema: {
227
+ operationId: 'create' + capitalize(entity.singularName),
228
+ summary: `Create ${entity.singularName}.`,
229
+ description: `Add new ${entity.singularName} to the database.`,
230
+ body: entitySchemaInput,
231
+ tags: [entity.table],
232
+ querystring: {
233
+ type: 'object',
234
+ properties: {
235
+ fields
236
+ },
237
+ additionalProperties: false
238
+ },
239
+ response: {
240
+ 200: entitySchema
241
+ }
183
242
  },
243
+ links: {
244
+ 200: entityLinks
245
+ }
184
246
  },
185
- links: {
186
- 200: entityLinks,
187
- },
188
- }, async function (request, reply) {
189
- const ctx = { app: this, reply }
190
- const res = await entity.save({ input: request.body, ctx })
191
- reply.header('location', `${app.prefix}/${res.id}`)
192
- return res
193
- })
247
+ async function (request, reply) {
248
+ const { fields } = request.query
249
+ const ctx = { app: this, reply }
250
+
251
+ let queryFields
252
+ if (fields) {
253
+ queryFields = [...fields]
254
+ for (const key of entity.primaryKeys.values()) {
255
+ if (!fields.includes(key)) {
256
+ queryFields.push(key)
257
+ }
258
+ }
259
+ }
260
+
261
+ const res = await entity.save({ input: request.body, ctx, fields: queryFields })
262
+
263
+ reply.header('location', `${app.prefix}/${res[[...entity.primaryKeys][0]]}`)
264
+
265
+ if (fields) {
266
+ for (const key of entity.primaryKeys.values()) {
267
+ if (!fields.includes(key)) {
268
+ delete res[key]
269
+ }
270
+ }
271
+ }
272
+
273
+ return res
274
+ }
275
+ )
276
+ }
277
+
278
+ const parseNullableValue = (field, value) => {
279
+ const fieldIsNullable = entity.camelCasedFields[field].isNullable
280
+ if (fieldIsNullable && typeof value === 'string' && value.toLowerCase() === 'null') {
281
+ return null
282
+ } else {
283
+ return value
284
+ }
194
285
  }
195
286
 
196
287
  const ignoredPUTRoute = ignoreRoutes.find(ignoreRoute => {
@@ -208,19 +299,19 @@ function rootEntityRoutes (app, entity, whereArgs, orderByArgs, entityLinks, ent
208
299
  type: 'object',
209
300
  properties: {
210
301
  fields,
211
- ...whereArgs,
302
+ ...whereArgs
212
303
  },
213
- additionalProperties: false,
304
+ additionalProperties: false
214
305
  },
215
306
  response: {
216
307
  200: {
217
308
  type: 'array',
218
- items: entitySchema,
219
- },
220
- },
309
+ items: entitySchema
310
+ }
311
+ }
221
312
  },
222
313
  links: {
223
- 200: entityLinks,
314
+ 200: entityLinks
224
315
  },
225
316
  async handler (request, reply) {
226
317
  const ctx = { app: this, reply }
@@ -238,49 +329,43 @@ function rootEntityRoutes (app, entity, whereArgs, orderByArgs, entityLinks, ent
238
329
  // TODO handle escaping of ,
239
330
  value = query[key].split(',')
240
331
  if (mapSQLTypeToOpenAPIType(entity.camelCasedFields[field].sqlType) === 'integer') {
241
- value = value.map((v) => parseInt(v))
332
+ value = value.map(v => parseInt(v))
242
333
  }
243
334
  }
244
- where[field][modifier] = value
335
+ where[field][modifier] = parseNullableValue(field, value)
245
336
  }
246
337
  }
247
338
 
248
339
  const res = await entity.updateMany({
249
340
  input: {
250
- ...request.body,
341
+ ...request.body
251
342
  },
252
343
  where,
253
344
  fields: request.query.fields,
254
- ctx,
345
+ ctx
255
346
  })
256
347
  // TODO: Should find a way to test this line
257
348
  // if (!res) return reply.callNotFound()
258
349
  reply.header('location', `${app.prefix}`)
259
350
  return res
260
- },
351
+ }
261
352
  })
262
353
  }
263
354
  }
264
355
 
265
- module.exports.rootEntityRoutes = rootEntityRoutes
266
-
267
- function capitalize (str) {
356
+ export function capitalize (str) {
268
357
  return str.charAt(0).toUpperCase() + str.slice(1)
269
358
  }
270
359
 
271
- module.exports.capitalize = capitalize
272
-
273
- function getFieldsForEntity (entity, ignore) {
360
+ export function getFieldsForEntity (entity, ignore) {
274
361
  return {
275
362
  type: 'array',
276
363
  items: {
277
364
  type: 'string',
278
365
  enum: Object.keys(entity.fields)
279
- .map((field) => entity.fields[field].camelcase)
280
- .filter((field) => !ignore[field])
281
- .sort(),
282
- },
366
+ .map(field => entity.fields[field].camelcase)
367
+ .filter(field => !ignore[field])
368
+ .sort()
369
+ }
283
370
  }
284
371
  }
285
-
286
- module.exports.getFieldsForEntity = getFieldsForEntity
package/lib/utils.js CHANGED
@@ -1,5 +1,3 @@
1
- 'use strict'
2
-
3
1
  const openApiPathItemNonOperationKeys = ['summary', 'description', 'servers', 'parameters']
4
2
 
5
3
  function removeOperationPropertiesFromOpenApiPathItem (pathItem) {
@@ -13,7 +11,7 @@ function removeOperationPropertiesFromOpenApiPathItem (pathItem) {
13
11
  }, {})
14
12
  }
15
13
 
16
- function getSchemaOverrideFromOpenApiPathItem (pathItem, method) {
14
+ export function getSchemaOverrideFromOpenApiPathItem (pathItem, method) {
17
15
  method = method?.toLowerCase()
18
16
 
19
17
  const schemaOverride = removeOperationPropertiesFromOpenApiPathItem(pathItem)
@@ -22,12 +20,8 @@ function getSchemaOverrideFromOpenApiPathItem (pathItem, method) {
22
20
  return schemaOverride
23
21
  }
24
22
 
25
- Object.keys(pathItem[method]).forEach((key) => {
23
+ Object.keys(pathItem[method]).forEach(key => {
26
24
  schemaOverride[key] = pathItem[method][key]
27
25
  })
28
26
  return schemaOverride
29
27
  }
30
-
31
- module.exports = {
32
- getSchemaOverrideFromOpenApiPathItem,
33
- }
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@platformatic/sql-openapi",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "Map a SQL database to OpenAPI, for Fastify",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "types": "index.d.ts",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/platformatic/platformatic.git"
10
11
  },
11
- "author": "Matteo Collina <hello@matteocollina.com>",
12
+ "author": "Platformatic Inc. <oss@platformatic.dev> (https://platformatic.dev)",
12
13
  "license": "Apache-2.0",
13
14
  "bugs": {
14
15
  "url": "https://github.com/platformatic/platformatic/issues"
@@ -16,41 +17,43 @@
16
17
  "homepage": "https://github.com/platformatic/platformatic#readme",
17
18
  "devDependencies": {
18
19
  "@matteo.collina/snap": "^0.3.0",
19
- "@matteo.collina/tspl": "^0.1.1",
20
- "borp": "^0.17.0",
20
+ "cleaner-spec-reporter": "^0.5.0",
21
21
  "eslint": "^9.4.0",
22
22
  "fastify": "^5.0.0",
23
- "neostandard": "^0.11.1",
23
+ "neostandard": "^0.12.0",
24
24
  "openapi-types": "^12.1.3",
25
- "tsd": "^0.31.0",
25
+ "tsd": "^0.33.0",
26
26
  "typescript": "^5.5.4",
27
27
  "why-is-node-running": "^2.2.2",
28
28
  "yaml": "^2.4.1",
29
- "@platformatic/sql-mapper": "3.4.1"
29
+ "@platformatic/sql-mapper": "3.5.0"
30
30
  },
31
31
  "dependencies": {
32
- "@fastify/deepmerge": "^1.3.0",
32
+ "@fastify/deepmerge": "^2.0.0",
33
33
  "@fastify/error": "^4.0.0",
34
34
  "@fastify/swagger": "^9.0.0",
35
- "@scalar/fastify-api-reference": "^1.19.5",
35
+ "@scalar/fastify-api-reference": "1.34.6",
36
36
  "camelcase": "^6.3.0",
37
37
  "fastify-plugin": "^5.0.0",
38
38
  "inflected": "^2.1.0",
39
- "@platformatic/scalar-theme": "3.4.1",
40
- "@platformatic/sql-json-schema-mapper": "3.4.1",
41
- "@platformatic/utils": "3.4.1"
39
+ "@platformatic/foundation": "3.5.0",
40
+ "@platformatic/scalar-theme": "3.5.0",
41
+ "@platformatic/sql-json-schema-mapper": "3.5.0"
42
42
  },
43
43
  "tsd": {
44
44
  "directory": "test/types"
45
45
  },
46
+ "engines": {
47
+ "node": ">=22.19.0"
48
+ },
46
49
  "scripts": {
47
50
  "lint": "eslint",
48
- "test": "npm run lint && npm run test:typescript && npm run test:postgresql && npm run test:mariadb && npm run test:mysql && npm run test:mysql8 && npm run test:sqlite",
49
- "test:postgresql": "DB=postgresql borp --timeout=180000 --concurrency=1 test/*.test.js",
50
- "test:mariadb": "DB=mariadb borp --timeout=180000 --concurrency=1",
51
- "test:mysql": "DB=mysql borp --timeout=180000 --concurrency=1",
52
- "test:mysql8": "DB=mysql8 borp --timeout=180000 --concurrency=1",
53
- "test:sqlite": "DB=sqlite borp --timeout=180000 --concurrency=1",
51
+ "test": "npm run test:typescript && npm run test:postgresql && npm run test:mariadb && npm run test:mysql && npm run test:mysql8 && npm run test:sqlite",
52
+ "test:postgresql": "DB=postgresql node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
53
+ "test:mariadb": "DB=mariadb node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
54
+ "test:mysql": "DB=mysql node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
55
+ "test:mysql8": "DB=mysql8 node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
56
+ "test:sqlite": "DB=sqlite node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
54
57
  "test:typescript": "tsd"
55
58
  }
56
59
  }