@vida-global/core 2.3.0 → 2.3.2

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.
@@ -70,6 +70,7 @@ const InstanceMethods = {
70
70
 
71
71
  validateIsString(value, options) {
72
72
  if (typeof value !== 'string') return 'must be a string';
73
+ if (options?.allowEmpty === false && value.length === 0) return 'must not be empty';
73
74
 
74
75
  if (options?.length?.gte !== undefined) {
75
76
  if (value.length < options.length.gte) return `must be greater than or equal to ${options.length.gte} characters`;
@@ -56,9 +56,18 @@ Responses with a status `>= 400` additionally document an `errors` object (`{ me
56
56
 
57
57
  Parameters and request bodies are **derived automatically** from the action's `parametersFor<ActionName>` validator definitions (see [Validations](./validations.md)) — there is no need to repeat them in YAML. Each declared parameter is placed by location:
58
58
 
59
- - **Path parameters** — any parameter whose name appears in the route (e.g. `:id`). Always required.
59
+ - **Path parameters** — every placeholder in the route (e.g. `:id`). Always required. A matching
60
+ validator definition supplies its schema; otherwise it defaults to a string.
60
61
  - **Query parameters** — for `GET`/`DELETE` actions, or any parameter marked `{ queryParameter: true }`.
61
- - **Request body** — for `POST`/`PUT` actions, declared params become a `requestBody` object schema; non-`optional` params are listed as `required`. The body defaults to `application/json`, but switches to `multipart/form-data` automatically when any body param declares `{ format: 'binary' }` (rendered as `{ type: 'string', format: 'binary' }` — the standard way to document a file upload).
62
+ - **Request body** — for `POST`/`PUT`/`PATCH` actions, declared params become a
63
+ `requestBody` object schema;
64
+ non-`optional` params are listed as `required`. The body defaults to `application/json`, but
65
+ switches to `multipart/form-data` automatically when any body param declares
66
+ `{ format: 'binary' }` (rendered as `{ type: 'string', format: 'binary' }` — the standard way to
67
+ document a file upload).
68
+
69
+ An action YAML file may provide a complete `requestBody` for an exceptional `POST`, `PUT`, or
70
+ `PATCH` transport contract.
62
71
 
63
72
  The validator definition itself becomes the parameter's JSON schema, and `example`, `default`, `format`, and `nullable` pass through when present. Set `{ nullable: true }` to document a value that may be `null`.
64
73
 
@@ -73,6 +82,26 @@ static parametersForPostImport() {
73
82
  }
74
83
  ```
75
84
 
85
+ When middleware provides a body field that is not available through controller parameters, the
86
+ action YAML may provide a complete OpenAPI `requestBody`. The explicit definition replaces inferred
87
+ body documentation for that action:
88
+
89
+ ```yaml
90
+ summary: Upload a file
91
+ description: Uploads one file.
92
+ requestBody:
93
+ required: true
94
+ content:
95
+ multipart/form-data:
96
+ schema:
97
+ type: object
98
+ required: [file]
99
+ properties:
100
+ file:
101
+ type: string
102
+ format: binary
103
+ ```
104
+
76
105
 
77
106
  ## Tags
78
107
 
@@ -29,7 +29,7 @@ class UsersController extends VidaServerController {
29
29
  - `{ presence: true }`
30
30
  - `{ isInteger: true }` or `{ isInteger: { gte: 0, lte: 100 } }`
31
31
  - `{ isNumber: true }` or `{ isNumber: { gte: 0, lte: 9.99 } }` — accepts integers and decimals.
32
- - `{ isString: true }` or `{ isString: { length: { gte: 10, lte: 100 }, regex: /foo/ } }`
32
+ - `{ isString: true }` or `{ isString: { allowEmpty: false, length: { gte: 10, lte: 100 }, regex: /foo/ } }` — `allowEmpty` defaults to `true`; when `false`, it rejects `''` but allows whitespace-only strings.
33
33
  - `{ isBoolean: true }` or `{ isBoolean: false }` (asserts the exact value)
34
34
  - `{ isDateTime: true }`
35
35
  - `{ isEnum: { enums: ['a', 'b', 'c'], error: 'optional message' } }`
@@ -263,6 +263,8 @@ class ApiDocGenerator {
263
263
 
264
264
 
265
265
  get requestBody() {
266
+ if (this.#details.requestBody) return structuredClone(this.#details.requestBody);
267
+
266
268
  const properties = this.bodyProperties;
267
269
  if (!Object.keys(properties).length) return undefined;
268
270
 
@@ -289,15 +291,18 @@ class ApiDocGenerator {
289
291
 
290
292
 
291
293
  get pathParams() {
292
- const params = this.#allRequestInputs.filter(([name, _]) => this.#pathParamNames.includes(name));
293
- return this.#formatInputs(Object.fromEntries(params), 'path');
294
+ const params = {};
295
+ for (const name of this.#pathParamNames) {
296
+ params[name] = this.#requestInputsByName[name] || { isString: true };
297
+ }
298
+ return this.#formatInputs(params, 'path');
294
299
  }
295
300
 
296
301
 
297
302
  get queryParams() {
298
303
  const params = this.#allRequestInputs.filter(([name, details]) => {
299
304
  if (this.#pathParamNames.includes(name)) return false;
300
- if (details?.queryParameter === true) return true;
305
+ if (details?.queryParameter === true) return true;
301
306
  return this.method == 'get' || this.method == 'delete';
302
307
  });
303
308
  return this.#formatInputs(Object.fromEntries(params), 'query');
@@ -305,9 +310,10 @@ class ApiDocGenerator {
305
310
 
306
311
 
307
312
  get bodyProperties() {
308
- const inputs = this.#allRequestInputs.filter(([name, _]) => {
313
+ const inputs = this.#allRequestInputs.filter(([name, details]) => {
309
314
  if (this.#pathParamNames.includes(name)) return false;
310
- return this.method == 'post' || this.method == 'put';
315
+ if (details?.queryParameter === true) return false;
316
+ return ['post', 'put', 'patch'].includes(this.method);
311
317
  });
312
318
  return this.#formatInputs(Object.fromEntries(inputs), 'body');
313
319
  }
@@ -75,6 +75,7 @@ class Schemas {
75
75
  if (options?.length?.gte !== undefined) schema.minLength = options.length.gte;
76
76
  if (options?.length?.lte !== undefined) schema.maxLength = options.length.lte;
77
77
  if (options?.regex) schema.pattern = options.regex.source;
78
+ if (options?.allowEmpty === false) schema.minLength = Math.max(schema.minLength || 0, 1);
78
79
  return schema;
79
80
  }
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -126,6 +126,29 @@ describe('ApiDocGenerator', () => {
126
126
  });
127
127
  });
128
128
 
129
+ it ('documents every route placeholder when no validator definition exists', () => {
130
+ const action = { action: 'getRecord', method: 'GET', path: '/account/:accountId/job/:jobId' };
131
+ ControllerClass.parametersForAction.mockReturnValue({});
132
+ const generator = new ApiDocGenerator(action, ControllerClass);
133
+
134
+ expect(generator.pathParams).toEqual({
135
+ accountId: {
136
+ name: 'accountId',
137
+ in: 'path',
138
+ required: true,
139
+ description: '',
140
+ schema: { type: 'string' },
141
+ },
142
+ jobId: {
143
+ name: 'jobId',
144
+ in: 'path',
145
+ required: true,
146
+ description: '',
147
+ schema: { type: 'string' },
148
+ },
149
+ });
150
+ });
151
+
129
152
  it ('marks path params required even when the definition is optional', () => {
130
153
  const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
131
154
  ControllerClass.parametersForAction.mockReturnValue({ id: { isString: true, optional: true } });
@@ -190,6 +213,7 @@ describe('ApiDocGenerator', () => {
190
213
 
191
214
  expect(Object.keys(generator.queryParams)).toEqual(['detail']);
192
215
  });
216
+
193
217
  });
194
218
 
195
219
 
@@ -217,6 +241,20 @@ describe('ApiDocGenerator', () => {
217
241
 
218
242
  expect(generator.bodyProperties).toEqual({});
219
243
  });
244
+
245
+ it ('builds body properties for PATCH actions', () => {
246
+ const action = { action: 'patchRecord', method: 'PATCH', path: '/things/:id' };
247
+ ControllerClass.parametersForAction.mockReturnValue({
248
+ id: { isInteger: true },
249
+ name: { isString: true },
250
+ });
251
+ const generator = new ApiDocGenerator(action, ControllerClass);
252
+
253
+ expect(generator.bodyProperties).toEqual({
254
+ name: { type: 'string', description: '' },
255
+ });
256
+ });
257
+
220
258
  });
221
259
 
222
260
 
@@ -620,6 +658,29 @@ describe('ApiDocGenerator', () => {
620
658
  });
621
659
  });
622
660
 
661
+ it ('uses an explicit requestBody from action YAML for middleware-backed input', () => {
662
+ const requestBody = {
663
+ required: true,
664
+ content: {
665
+ 'multipart/form-data': {
666
+ schema: {
667
+ type: 'object',
668
+ properties: {
669
+ file: { type: 'string', format: 'binary' },
670
+ },
671
+ required: ['file'],
672
+ },
673
+ },
674
+ },
675
+ };
676
+ const generator = documented({
677
+ details: { requestBody },
678
+ params: { path: { isString: true } },
679
+ });
680
+
681
+ expect(generator.documentation.requestBody).toEqual(requestBody);
682
+ });
683
+
623
684
  it ('includes security when the action is authenticated', () => {
624
685
  const generator = documented({ params: {}, security: [{ apiKeyAuth: [] }] });
625
686