@furystack/rest-service 10.0.22 → 10.0.24

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 (66) hide show
  1. package/esm/api-manager.d.ts +47 -1
  2. package/esm/api-manager.d.ts.map +1 -1
  3. package/esm/api-manager.js +19 -5
  4. package/esm/api-manager.js.map +1 -1
  5. package/esm/authenticate.d.ts.map +1 -1
  6. package/esm/authenticate.js +3 -1
  7. package/esm/authenticate.js.map +1 -1
  8. package/esm/endpoint-generators/create-get-schema-action.d.ts +16 -0
  9. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -0
  10. package/esm/endpoint-generators/create-get-schema-action.js +21 -0
  11. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -0
  12. package/esm/endpoint-generators/create-get-swagger-json-action.d.ts +14 -0
  13. package/esm/endpoint-generators/create-get-swagger-json-action.d.ts.map +1 -0
  14. package/esm/endpoint-generators/create-get-swagger-json-action.js +17 -0
  15. package/esm/endpoint-generators/create-get-swagger-json-action.js.map +1 -0
  16. package/esm/endpoint-generators/index.d.ts +1 -0
  17. package/esm/endpoint-generators/index.d.ts.map +1 -1
  18. package/esm/endpoint-generators/index.js +1 -0
  19. package/esm/endpoint-generators/index.js.map +1 -1
  20. package/esm/endpoint-generators/with-schema-and-swagger-action.d.ts +21 -0
  21. package/esm/endpoint-generators/with-schema-and-swagger-action.d.ts.map +1 -0
  22. package/esm/endpoint-generators/with-schema-and-swagger-action.js +2 -0
  23. package/esm/endpoint-generators/with-schema-and-swagger-action.js.map +1 -0
  24. package/esm/get-schema-from-api.d.ts +10 -0
  25. package/esm/get-schema-from-api.d.ts.map +1 -0
  26. package/esm/get-schema-from-api.js +55 -0
  27. package/esm/get-schema-from-api.js.map +1 -0
  28. package/esm/get-schema-from-api.spec.d.ts +2 -0
  29. package/esm/get-schema-from-api.spec.d.ts.map +1 -0
  30. package/esm/get-schema-from-api.spec.js +68 -0
  31. package/esm/get-schema-from-api.spec.js.map +1 -0
  32. package/esm/helpers.d.ts +4 -1
  33. package/esm/helpers.d.ts.map +1 -1
  34. package/esm/index.d.ts +10 -9
  35. package/esm/index.d.ts.map +1 -1
  36. package/esm/index.js +10 -9
  37. package/esm/index.js.map +1 -1
  38. package/esm/swagger/generate-swagger-json.d.ts +14 -0
  39. package/esm/swagger/generate-swagger-json.d.ts.map +1 -0
  40. package/esm/swagger/generate-swagger-json.js +106 -0
  41. package/esm/swagger/generate-swagger-json.js.map +1 -0
  42. package/esm/swagger/generate-swagger-json.spec.d.ts +2 -0
  43. package/esm/swagger/generate-swagger-json.spec.d.ts.map +1 -0
  44. package/esm/swagger/generate-swagger-json.spec.js +204 -0
  45. package/esm/swagger/generate-swagger-json.spec.js.map +1 -0
  46. package/esm/validate.d.ts.map +1 -1
  47. package/esm/validate.integration.spec.js +154 -2
  48. package/esm/validate.integration.spec.js.map +1 -1
  49. package/esm/validate.integration.spec.schema.json +0 -2
  50. package/esm/validate.js +4 -1
  51. package/esm/validate.js.map +1 -1
  52. package/package.json +9 -9
  53. package/src/api-manager.ts +68 -5
  54. package/src/authenticate.ts +5 -1
  55. package/src/endpoint-generators/create-get-schema-action.ts +29 -0
  56. package/src/endpoint-generators/create-get-swagger-json-action.ts +26 -0
  57. package/src/endpoint-generators/index.ts +1 -0
  58. package/src/endpoint-generators/with-schema-and-swagger-action.ts +11 -0
  59. package/src/get-schema-from-api.spec.ts +73 -0
  60. package/src/get-schema-from-api.ts +74 -0
  61. package/src/index.ts +10 -9
  62. package/src/swagger/generate-swagger-json.spec.ts +239 -0
  63. package/src/swagger/generate-swagger-json.ts +123 -0
  64. package/src/validate.integration.spec.schema.json +0 -2
  65. package/src/validate.integration.spec.ts +173 -2
  66. package/src/validate.ts +6 -1
@@ -0,0 +1,123 @@
1
+ import type { ApiEndpointDefinition, Operation, ParameterObject, SwaggerDocument } from '@furystack/rest'
2
+
3
+ /**
4
+ * Converts a FuryStack API schema to an OpenAPI 3.1 compatible document
5
+ *
6
+ * @param schema - The FuryStack API schema to convert
7
+ * @returns A SwaggerDocument in OpenAPI 3.1 format
8
+ */
9
+ export const generateSwaggerJsonFromApiSchema = ({
10
+ api,
11
+ title = 'FuryStack API',
12
+ description = 'API documentation generated from FuryStack API schema',
13
+ version = '1.0.0',
14
+ }: {
15
+ api: Record<string, ApiEndpointDefinition>
16
+ title?: string
17
+ description?: string
18
+ version?: string
19
+ }): SwaggerDocument => {
20
+ const swaggerJson: SwaggerDocument = {
21
+ openapi: '3.1.0',
22
+ info: {
23
+ title,
24
+ version,
25
+ description,
26
+ },
27
+ jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base',
28
+ servers: [{ url: '/' }],
29
+ tags: [],
30
+ paths: {},
31
+ components: {
32
+ schemas: {},
33
+ securitySchemes: {
34
+ cookieAuth: {
35
+ type: 'apiKey',
36
+ in: 'cookie',
37
+ name: 'session',
38
+ },
39
+ },
40
+ },
41
+ }
42
+
43
+ for (const [path, definition] of Object.entries(api)) {
44
+ // Normalize path to OpenAPI format (convert :param to {param})
45
+ const normalizedPath = path.replace(/:([^/]+)/g, '{$1}')
46
+ if (!swaggerJson.paths![normalizedPath]) {
47
+ swaggerJson.paths![normalizedPath] = {}
48
+ }
49
+
50
+ // Extract path parameters
51
+ const pathParams = Array.from(path.matchAll(/:([^/]+)/g), (m) => m[1])
52
+ const parameters: ParameterObject[] = pathParams.map((param) => ({
53
+ name: param,
54
+ in: 'path',
55
+ required: true,
56
+ description: `Path parameter: ${param}`,
57
+ schema: { type: 'string' },
58
+ }))
59
+
60
+ // Build operation
61
+ const method = definition.method.toLowerCase()
62
+ const operation: Operation = {
63
+ summary: `${definition.method} ${path}`,
64
+ description: `Endpoint for ${path}`,
65
+ operationId: `${method}${path.replace(/\//g, '_').replace(/:/g, '').replace(/-/g, '_')}`,
66
+ security: definition.isAuthenticated ? [{ cookieAuth: [] }] : [],
67
+ parameters,
68
+ responses: {
69
+ '200': {
70
+ description: 'Successful operation',
71
+ content: {
72
+ 'application/json': {
73
+ schema: definition.schemaName
74
+ ? { $ref: `#/components/schemas/${definition.schemaName}` }
75
+ : { type: 'object' },
76
+ },
77
+ },
78
+ },
79
+ '401': { description: 'Unauthorized' },
80
+ '500': { description: 'Internal server error' },
81
+ },
82
+ }
83
+
84
+ // Add schema to components if not already there
85
+ if (definition.schema && definition.schemaName) {
86
+ swaggerJson.components!.schemas![definition.schemaName] = definition.schema
87
+ }
88
+
89
+ // Assign the operation to the correct HTTP method property of PathItem
90
+ const pathItem = swaggerJson.paths![normalizedPath]
91
+ switch (method) {
92
+ case 'get':
93
+ pathItem.get = operation
94
+ break
95
+ case 'put':
96
+ pathItem.put = operation
97
+ break
98
+ case 'post':
99
+ pathItem.post = operation
100
+ break
101
+ case 'delete':
102
+ pathItem.delete = operation
103
+ break
104
+ case 'options':
105
+ pathItem.options = operation
106
+ break
107
+ case 'head':
108
+ pathItem.head = operation
109
+ break
110
+ case 'patch':
111
+ pathItem.patch = operation
112
+ break
113
+ case 'trace':
114
+ pathItem.trace = operation
115
+ break
116
+ default:
117
+ // Ignore unknown methods
118
+ break
119
+ }
120
+ }
121
+
122
+ return swaggerJson
123
+ }
@@ -6,7 +6,6 @@
6
6
  "description": "Endpoint model for deleting entities",
7
7
  "properties": {
8
8
  "result": {
9
- "additionalProperties": false,
10
9
  "type": "object"
11
10
  },
12
11
  "url": {
@@ -304,7 +303,6 @@
304
303
  "type": "object"
305
304
  },
306
305
  "result": {
307
- "additionalProperties": false,
308
306
  "type": "object"
309
307
  },
310
308
  "url": {
@@ -1,6 +1,7 @@
1
1
  import { getStoreManager, InMemoryStore, User } from '@furystack/core'
2
2
  import { getPort } from '@furystack/core/port-generator'
3
3
  import { Injector } from '@furystack/inject'
4
+ import type { SwaggerDocument, WithSchemaAction } from '@furystack/rest'
4
5
  import { createClient, ResponseError } from '@furystack/rest-client-fetch'
5
6
  import { usingAsync } from '@furystack/utils'
6
7
  import type Ajv from 'ajv'
@@ -14,15 +15,23 @@ import { Validate } from './validate.js'
14
15
 
15
16
  // To recreate: yarn ts-json-schema-generator -f tsconfig.json --no-type-check -p packages/rest-service/src/validate.integration.schema.ts -o packages/rest-service/src/validate.integration.spec.schema.json
16
17
 
17
- const createValidateApi = async () => {
18
+ const name = crypto.randomUUID()
19
+ const description = crypto.randomUUID()
20
+ const version = crypto.randomUUID()
21
+
22
+ const createValidateApi = async (options = { enableGetSchema: false }) => {
18
23
  const injector = new Injector()
19
24
  const port = getPort()
20
25
 
21
26
  getStoreManager(injector).addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
22
27
  getStoreManager(injector).addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
23
28
 
24
- await useRestService<ValidationApi>({
29
+ const api = await useRestService<ValidationApi>({
25
30
  injector,
31
+ enableGetSchema: options.enableGetSchema,
32
+ name,
33
+ description,
34
+ version,
26
35
  api: {
27
36
  GET: {
28
37
  '/validate-query': Validate({
@@ -66,11 +75,173 @@ const createValidateApi = async () => {
66
75
 
67
76
  return {
68
77
  [Symbol.asyncDispose]: injector[Symbol.asyncDispose].bind(injector),
78
+ injector,
79
+ api,
69
80
  client,
70
81
  }
71
82
  }
72
83
 
73
84
  describe('Validation integration tests', () => {
85
+ describe('swagger.json schema definition', () => {
86
+ it('Should include name, description and version in the generated swagger.json', async () => {
87
+ await usingAsync(await createValidateApi({ enableGetSchema: true }), async ({ client }) => {
88
+ const result = await (client as ReturnType<typeof createClient<any>>)({
89
+ method: 'GET',
90
+ action: '/swagger.json',
91
+ })
92
+
93
+ expect(result.response.status).toBe(200)
94
+ expect(result.result).toBeDefined()
95
+
96
+ // Verify swagger document structure
97
+ const swaggerJson = result.result as SwaggerDocument
98
+ expect(swaggerJson.openapi).toBe('3.1.0')
99
+ expect(swaggerJson.info).toBeDefined()
100
+ expect(swaggerJson.info?.title).toBe(name)
101
+ expect(swaggerJson.info?.description).toBe(description)
102
+ expect(swaggerJson.info?.version).toBe(version)
103
+ })
104
+ })
105
+
106
+ it('Should return a 404 when not enabled', async () => {
107
+ await usingAsync(await createValidateApi({ enableGetSchema: false }), async ({ client }) => {
108
+ try {
109
+ await (client as ReturnType<typeof createClient<any>>)({
110
+ method: 'GET',
111
+ action: '/swagger.json',
112
+ })
113
+ expect.fail('Expected response error but got success')
114
+ } catch (error) {
115
+ expect(error).toBeInstanceOf(ResponseError)
116
+ expect((error as ResponseError).response.status).toBe(404)
117
+ }
118
+ })
119
+ })
120
+
121
+ it('Should return a generated swagger.json when enabled', async () => {
122
+ await usingAsync(await createValidateApi({ enableGetSchema: true }), async ({ client }) => {
123
+ const result = await (client as ReturnType<typeof createClient<any>>)({
124
+ method: 'GET',
125
+ action: '/swagger.json',
126
+ })
127
+
128
+ expect(result.response.status).toBe(200)
129
+ expect(result.result).toBeDefined()
130
+
131
+ // Verify swagger document structure
132
+ const swaggerJson = result.result as SwaggerDocument
133
+ expect(swaggerJson.openapi).toBe('3.1.0')
134
+ expect(swaggerJson.info).toBeDefined()
135
+ expect(swaggerJson.info?.title).toBe(name)
136
+ expect(swaggerJson.info?.description).toBe(description)
137
+ expect(swaggerJson.info?.version).toBe(version)
138
+ expect(swaggerJson.paths).toBeDefined()
139
+
140
+ // Verify our API endpoints are included
141
+ expect(swaggerJson.paths?.['/validate-query']).toBeDefined()
142
+ expect(swaggerJson.paths?.['/validate-url/{id}']).toBeDefined()
143
+ expect(swaggerJson.paths?.['/validate-headers']).toBeDefined()
144
+ expect(swaggerJson.paths?.['/validate-body']).toBeDefined()
145
+
146
+ // Verify components section
147
+ expect(swaggerJson.components).toBeDefined()
148
+ expect(swaggerJson.components?.schemas).toBeDefined()
149
+ expect(swaggerJson.components?.schemas?.ValidateQuery).toBeDefined()
150
+ expect(swaggerJson.components?.schemas?.ValidateUrl).toBeDefined()
151
+ expect(swaggerJson.components?.schemas?.ValidateHeaders).toBeDefined()
152
+ expect(swaggerJson.components?.schemas?.ValidateBody).toBeDefined()
153
+ })
154
+ })
155
+ })
156
+
157
+ describe('Validation metadata', () => {
158
+ it('Should return 404 when not enabled', async () => {
159
+ await usingAsync(await createValidateApi({ enableGetSchema: false }), async ({ client }) => {
160
+ try {
161
+ await (client as ReturnType<typeof createClient<WithSchemaAction<ValidationApi>>>)({
162
+ method: 'GET',
163
+ action: '/schema',
164
+ headers: {
165
+ accept: 'application/schema+json',
166
+ },
167
+ })
168
+ } catch (error) {
169
+ expect(error).toBeInstanceOf(ResponseError)
170
+ expect((error as ResponseError).response.status).toBe(404)
171
+ }
172
+ })
173
+ })
174
+
175
+ it('Should return a 406 when the accept header is not supported', async () => {
176
+ expect.assertions(2)
177
+ await usingAsync(await createValidateApi({ enableGetSchema: true }), async ({ client }) => {
178
+ try {
179
+ await (client as ReturnType<typeof createClient<WithSchemaAction<ValidationApi>>>)({
180
+ method: 'GET',
181
+ action: '/schema',
182
+ headers: {
183
+ accept: 'text/plain' as any,
184
+ },
185
+ })
186
+ } catch (error) {
187
+ expect(error).toBeInstanceOf(ResponseError)
188
+ expect((error as ResponseError).response.status).toBe(406)
189
+ }
190
+ })
191
+ })
192
+
193
+ it('Should return the validation metadata', async () => {
194
+ await usingAsync(await createValidateApi({ enableGetSchema: true }), async ({ client }) => {
195
+ const result = await (client as ReturnType<typeof createClient<WithSchemaAction<ValidationApi>>>)({
196
+ method: 'GET',
197
+ action: '/schema',
198
+ headers: {
199
+ accept: 'application/schema+json',
200
+ },
201
+ })
202
+
203
+ expect(result.response.status).toBe(200)
204
+ expect(result.result).toBeDefined()
205
+
206
+ expect(result.result.name).toBe(name)
207
+ expect(result.result.description).toBe(description)
208
+ expect(result.result.version).toBe(version)
209
+
210
+ expect(result.result.endpoints['/validate-query']).toBeDefined()
211
+
212
+ expect(result.result.endpoints['/validate-query'].schema).toStrictEqual(schema)
213
+ expect(result.result.endpoints['/validate-query'].schemaName).toBe('ValidateQuery')
214
+ expect(result.result.endpoints['/validate-query'].method).toBe('GET')
215
+ expect(result.result.endpoints['/validate-query'].path).toBe('/validate-query')
216
+ expect(result.result.endpoints['/validate-query'].isAuthenticated).toBe(false)
217
+
218
+ expect(result.result.endpoints['/validate-url/:id']).toBeDefined()
219
+ expect(result.result.endpoints['/validate-url/:id'].schema).toStrictEqual(schema)
220
+ expect(result.result.endpoints['/validate-url/:id'].schemaName).toBe('ValidateUrl')
221
+ expect(result.result.endpoints['/validate-url/:id'].method).toBe('GET')
222
+ expect(result.result.endpoints['/validate-url/:id'].path).toBe('/validate-url/:id')
223
+ expect(result.result.endpoints['/validate-url/:id'].isAuthenticated).toBe(false)
224
+
225
+ expect(result.result.endpoints['/validate-headers']).toBeDefined()
226
+ expect(result.result.endpoints['/validate-headers'].schema).toStrictEqual(schema)
227
+ expect(result.result.endpoints['/validate-headers'].schemaName).toBe('ValidateHeaders')
228
+ expect(result.result.endpoints['/validate-headers'].method).toBe('GET')
229
+ expect(result.result.endpoints['/validate-headers'].path).toBe('/validate-headers')
230
+ expect(result.result.endpoints['/validate-headers'].isAuthenticated).toBe(false)
231
+
232
+ expect(result.result.endpoints['/validate-body']).toBeDefined()
233
+ expect(result.result.endpoints['/validate-body'].schema).toStrictEqual(schema)
234
+ expect(result.result.endpoints['/validate-body'].schemaName).toBe('ValidateBody')
235
+ expect(result.result.endpoints['/validate-body'].method).toBe('POST')
236
+ expect(result.result.endpoints['/validate-body'].path).toBe('/validate-body')
237
+ expect(result.result.endpoints['/validate-body'].isAuthenticated).toBe(false)
238
+
239
+ expect(result.result.endpoints['/mock']).toBeUndefined()
240
+ expect(result.result.endpoints['/mock/:id']).toBeUndefined()
241
+ })
242
+ })
243
+ })
244
+
74
245
  describe('Validation errors', () => {
75
246
  it('Should validate query', async () => {
76
247
  await usingAsync(await createValidateApi(), async ({ client }) => {
package/src/validate.ts CHANGED
@@ -31,7 +31,7 @@ export const Validate =
31
31
 
32
32
  const validator = new SchemaValidator(schema, { coerceTypes: true, strict: false })
33
33
 
34
- return async (args: RequestActionOptions<T>): Promise<ActionResult<T>> => {
34
+ const wrapped = async (args: RequestActionOptions<T>): Promise<ActionResult<T>> => {
35
35
  const anyArgs = args as any
36
36
  let body!: any
37
37
  const { headers } = anyArgs
@@ -62,4 +62,9 @@ export const Validate =
62
62
  getBody: () => Promise.resolve(body),
63
63
  })
64
64
  }
65
+
66
+ wrapped.schema = schema
67
+ wrapped.schemaName = validationOptions.schemaName
68
+
69
+ return wrapped
65
70
  }