Skip to content

Commit af41312

Browse files
authoredMay 11, 2024··
fix: better error handling and more config options (#17)
1 parent f97fb2f commit af41312

17 files changed

+962
-97
lines changed
 

‎package-lock.json

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,20 @@ function mapValidatorErrors(validatorErrors: ErrorObject[] | null | undefined, s
6363
title: ajvErr.message ?? ET_VALIDATION,
6464
}
6565

66-
if (ajvErr.schemaPath) {
67-
mappedErr.source = {
68-
pointer: ajvErr.schemaPath,
66+
if (ajvErr.schemaPath || ajvErr.params) {
67+
mappedErr.source = {}
68+
69+
if (ajvErr.schemaPath) {
70+
mappedErr.source.pointer = ajvErr.schemaPath
71+
}
72+
if (ajvErr.params) {
73+
const filteredParams = Object.entries(ajvErr.params).filter(([key]) => key === 'additionalProperty' || key === 'missingProperty')
74+
if (filteredParams.length > 0) {
75+
mappedErr.source.parameter = Object.values(ajvErr.params).join(', ')
76+
}
6977
}
7078
}
79+
7180
mapped.push(mappedErr)
7281
})
7382
return mapped
@@ -320,6 +329,9 @@ export class AjvOpenApiValidator {
320329
if (this.validatorOpts.setAdditionalPropertiesToFalse) {
321330
if (!isValidReferenceObject(schema) && schema.additionalProperties === undefined && schema.discriminator === undefined) {
322331
schema.additionalProperties = false
332+
if (schema.type === undefined) {
333+
schema.type = 'object' // avoid missing type "object" for keyword "additionalProperties" error
334+
}
323335
}
324336
}
325337

‎packages/ajv-openapi-request-response-validator/src/openapi-validator.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export function unserializeParameters(parameters: Record<string, Primitive>): Re
143143
const lastKeyIndex = splitKey.length - 1
144144

145145
splitKey.forEach((part, index) => {
146-
const cleanPart = part.replace(']', '')
146+
const cleanPart = part.replace(/]/g, '')
147147

148148
if (index === lastKeyIndex) {
149149
target[cleanPart] = value
@@ -156,6 +156,7 @@ export function unserializeParameters(parameters: Record<string, Primitive>): Re
156156
})
157157
}
158158
}
159+
159160
return result
160161
}
161162

‎packages/ajv-openapi-request-response-validator/test/.eslintrc.js

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ module.exports = {
77
},
88
extends: ["../.eslintrc.js"],
99
rules: {
10+
"@typescript-eslint/no-magic-numbers": "off",
11+
"@typescript-eslint/no-explicit-any": "off",
12+
"no-console": "off",
1013
"@typescript-eslint/naming-convention": "off",
1114
"@typescript-eslint/no-non-null-assertion": "off",
1215
}

‎packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml

+256-1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ paths:
7676
schema:
7777
$ref: '#/components/schemas/OneOfExample'
7878

79+
'/one-of-example-list':
80+
get:
81+
responses:
82+
'200':
83+
description: Ok
84+
content:
85+
application/json:
86+
schema:
87+
$ref: '#/components/schemas/OneOfExampleList'
88+
7989
'/all-of-example':
8090
post:
8191
requestBody:
@@ -113,6 +123,86 @@ paths:
113123
application/json:
114124
schema:
115125
$ref: '#/components/schemas/OffsetPagination'
126+
127+
'/multipart':
128+
post:
129+
requestBody:
130+
content:
131+
image/gif:
132+
schema:
133+
type: string
134+
format: binary
135+
multipart/form-data:
136+
schema:
137+
type: object
138+
additionalProperties: true
139+
140+
'/users/{uid}/state/{sid}':
141+
put:
142+
operationId: put-user-state
143+
summary: Upserts a user state
144+
parameters:
145+
- name: uid
146+
in: path
147+
required: true
148+
schema:
149+
type: string
150+
- name: sid
151+
in: path
152+
description: The state id or key
153+
required: true
154+
schema:
155+
type: string
156+
requestBody:
157+
content:
158+
application/json:
159+
schema:
160+
$ref: '#/components/schemas/UserStateValue'
161+
required: true
162+
responses:
163+
'200':
164+
description: OK
165+
patch:
166+
operationId: patch-user-state
167+
summary: Patch a user state
168+
parameters:
169+
- name: uid
170+
in: path
171+
required: true
172+
schema:
173+
type: string
174+
- name: sid
175+
in: path
176+
description: The state id or key
177+
required: true
178+
schema:
179+
type: string
180+
requestBody:
181+
content:
182+
application/json:
183+
schema:
184+
type: array
185+
items:
186+
$ref: '#/components/schemas/PatchUserStateValue'
187+
188+
/webhooks/mytest/{provision}:
189+
post:
190+
parameters:
191+
- name: provision
192+
in: path
193+
required: true
194+
schema:
195+
type: string
196+
requestBody:
197+
content:
198+
application/json:
199+
schema:
200+
$ref: '#/components/schemas/MyTestProvisionResponse'
201+
required: true
202+
responses:
203+
'200':
204+
description: OK
205+
116206
components:
117207
responses:
118208
ResponseError:
@@ -309,6 +399,14 @@ components:
309399
discriminator:
310400
propertyName: objType
311401

402+
OneOfExampleList:
403+
type: object
404+
properties:
405+
items:
406+
type: array
407+
items:
408+
$ref: '#/components/schemas/OneOfExample'
409+
312410
AllOfExample:
313411
type: object
314412
description: Example using allOf
@@ -320,4 +418,161 @@ components:
320418
status:
321419
type: string
322420
enum: [pending, approved, rejected]
323-
421+
422+
UserStateValue:
423+
type: "object"
424+
oneOf:
425+
- $ref: '#/components/schemas/UserStateCoffeeCx'
426+
- $ref: '#/components/schemas/UserUploads'
427+
- $ref: '#/components/schemas/UserStateWidgets'
428+
discriminator:
429+
propertyName: type
430+
UserStateCoffeeCx:
431+
type: object
432+
required:
433+
- type
434+
- enabled
435+
properties:
436+
type:
437+
type: string
438+
enum: [coffeeCx]
439+
enabled:
440+
type: boolean
441+
description: Whether a certain flag, feature, etc. is enabled or not
442+
nullableTest:
443+
type: string
444+
nullable: true
445+
UserUploads:
446+
type: object
447+
required:
448+
- type
449+
- entries
450+
properties:
451+
type:
452+
type: string
453+
enum: [userUploads]
454+
entries:
455+
type: array
456+
maxItems: 1000
457+
items:
458+
$ref: '#/components/schemas/UserUploadEntry'
459+
UserUploadEntry:
460+
type: object
461+
required:
462+
- id
463+
- status
464+
- name
465+
- communityId
466+
properties:
467+
id:
468+
type: string
469+
uploadId:
470+
type: string
471+
status:
472+
type: string
473+
enum:
474+
- ongoing
475+
- completed
476+
- error
477+
name:
478+
type: string
479+
communityId:
480+
type: string
481+
path:
482+
description: >-
483+
path to which the file will be uploaded (e.g. General (default), BCP
484+
Article Files...)
485+
$ref: '#/components/schemas/SafePath'
486+
blobUrl:
487+
type: string
488+
url:
489+
type: string
490+
SafePath:
491+
type: string
492+
pattern: (?=(^(?!.*\.\.\/).+))(?=(^(?!.*\/\/).+))
493+
description: >-
494+
A valid path that matches any sequence of characters except if it
495+
contains '../', '//' (to avoid directory traversal attacks)
496+
maxLength: 255
497+
UserStateWidgets:
498+
type: object
499+
required:
500+
- type
501+
- widgets
502+
properties:
503+
type:
504+
type: string
505+
enum:
506+
- widgets
507+
widgets:
508+
type: array
509+
items:
510+
type: string
511+
oneOf:
512+
- minItems: 4
513+
maxItems: 4
514+
- minItems: 3
515+
maxItems: 3
516+
- minItems: 2
517+
maxItems: 2
518+
AnyValue:
519+
description: Accepts any valid JSON value
520+
additionalProperties: true
521+
oneOf:
522+
- type: string
523+
- type: number
524+
- type: integer
525+
- type: boolean
526+
- type: array
527+
items:
528+
$ref: '#/components/schemas/AnyValue'
529+
- type: object
530+
additionalProperties: true
531+
PatchUserStateValue:
532+
type: object
533+
required:
534+
- op
535+
- path
536+
- value
537+
properties:
538+
op:
539+
type: string
540+
enum:
541+
- replace
542+
- add
543+
- remove
544+
path:
545+
type: string
546+
value:
547+
$ref: '#/components/schemas/AnyValue'
548+
MyTestProvisionResponse:
549+
type: object
550+
required:
551+
- taskId
552+
properties:
553+
taskId:
554+
type: string
555+
stateBag:
556+
type: object
557+
required:
558+
- contentEntryId
559+
properties:
560+
contentEntryId:
561+
type: string
562+
guestUserUid:
563+
type: string
564+
siteInfo:
565+
type: object
566+
required:
567+
- url
568+
properties:
569+
url:
570+
type: string
571+
teamId:
572+
type: string
573+
groupId:
574+
type: string
575+
errorMessage:
576+
description: Reason why the job failed.
577+
type: string
578+

‎packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-allof-with-schema.js.txt

+1
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ module.exports = {
109109
{
110110
"code": "Validation-additionalProperties",
111111
"source": {
112+
"parameter": "bark",
112113
"pointer": "#/components/schemas/Cat/additionalProperties",
113114
},
114115
"status": 400,

‎packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-anyof-with-schema.js.txt

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ module.exports = {
7676
{
7777
"code": "Validation-required",
7878
"source": {
79+
"parameter": "age",
7980
"pointer": "#/components/schemas/PetByAge/required",
8081
},
8182
"status": 400,
@@ -84,6 +85,7 @@ module.exports = {
8485
{
8586
"code": "Validation-required",
8687
"source": {
88+
"parameter": "pet_type",
8789
"pointer": "#/components/schemas/PetByType/required",
8890
},
8991
"status": 400,

‎packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-oneof-with-schema.js.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,6 @@ module.exports = {
6666
},
6767
},
6868
expectedErrors: [
69-
{"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Cat/additionalProperties"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Dog/additionalProperties"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-oneOf", "source": {"pointer": "#/oneOf"}, "status": 400, "title": "must match exactly one schema in oneOf"}
69+
{"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Cat/additionalProperties", "parameter": "breed",}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Dog/additionalProperties", "parameter": "age"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-oneOf", "source": {"pointer": "#/oneOf"}, "status": 400, "title": "must match exactly one schema in oneOf"}
7070
]
7171
};

‎packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-default.js.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ module.exports = {
3131
expectedErrors: [
3232
{
3333
code: 'Validation-additionalProperties',
34-
source: {
34+
source: {
35+
"parameter": "something-else",
3536
pointer: '#/components/schemas/Test1/additionalProperties'
3637
},
3738
status: 400,

‎packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-ref.js.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ module.exports = {
2828
expectedErrors: [
2929
{
3030
code: 'Validation-required',
31-
source: {
31+
source: {
32+
"parameter": "foo",
3233
pointer: '#/components/schemas/Test1/required'
3334
},
3435
status: 400,

‎packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
import * as fs from 'fs'
3-
import * as path from 'path'
2+
import * as nodepath from 'path'
43
import { OpenAPIV3 } from 'openapi-types'
54
import copy from 'fast-copy'
65
import { createAjvInstance } from '../../src/ajv-factory'
@@ -64,8 +63,8 @@ const files = fs.readdirSync(fixtureDir)
6463
const testCases: { [key: string]: TestFixture } = {}
6564
for (const file of files) {
6665
if (file.endsWith('.js.txt')) {
67-
const testName = path.basename(file, '.js.txt').replace(/-/g, ' ')
68-
const fixtureContent = fs.readFileSync(path.resolve(fixtureDir, file), { encoding: 'utf-8' })
66+
const testName = nodepath.basename(file, '.js.txt').replace(/-/g, ' ')
67+
const fixtureContent = fs.readFileSync(nodepath.resolve(fixtureDir, file), { encoding: 'utf-8' })
6968
try {
7069
// eslint-disable-next-line no-eval
7170
const fixture: TestFixture = eval(fixtureContent)
@@ -116,14 +115,14 @@ describe('The api validator', () => {
116115

117116
if (fixture.validateArgs.paths) {
118117
const params = fixture.request.query ? fixture.request.query : {}
119-
for (const [methodPath, method] of Object.entries(fixture.validateArgs.paths)) {
118+
for (const [path, method] of Object.entries(fixture.validateArgs.paths)) {
120119
if (method) {
121120
for (const [methodName, methodDef] of Object.entries(method)) {
122121
if (Object.values(OpenAPIV3.HttpMethods).includes(methodName as OpenAPIV3.HttpMethods)) {
123122
const operation: OpenAPIV3.OperationObject<object> = methodDef
124123
if (operation.parameters) {
125124
const result = validator.validateQueryParams(
126-
methodPath,
125+
path,
127126
methodName,
128127
params,
129128
fixture.requestOpts?.strictQueryParamValidation ?? true
@@ -136,7 +135,7 @@ describe('The api validator', () => {
136135
}
137136
if (operation.requestBody && fixture.request.body) {
138137
const result = validator.validateRequestBody(
139-
methodPath,
138+
path,
140139
methodName,
141140
fixture.request.body,
142141
fixture.requestOpts?.strictRequestBodyValidation ?? true

‎packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts

+208-4
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('The api validator for the user api spec', () => {
2626
code: 'Validation-required',
2727
status: 500,
2828
title: "must have required property 'code'",
29-
source: { pointer: '#/required' },
29+
source: { pointer: '#/required', parameter: 'code' },
3030
},
3131
])
3232
})
@@ -52,7 +52,7 @@ describe('The api validator for the user api spec', () => {
5252
).toEqual([
5353
{
5454
code: 'Validation-additionalProperties',
55-
source: { pointer: '#/additionalProperties' },
55+
source: { pointer: '#/additionalProperties', parameter: 'unknownPro' },
5656
status: 500,
5757
title: 'must NOT have additional properties',
5858
},
@@ -73,12 +73,44 @@ describe('The api validator for the user api spec', () => {
7373
expect(validator.validateResponseBody('/one-of-example', 'get', '200', dataWithExtra)).toEqual(undefined)
7474
})
7575

76+
it('should succeed oneOf list with mixed results', () => {
77+
const dataWithExtraA = { name: 'test', description: 'hello', objType: 'a' }
78+
const dataWithExtraB = { somethingElse: 123, objType: 'b' }
79+
expect(
80+
validator.validateResponseBody('/one-of-example-list', 'get', '200', {
81+
items: [dataWithExtraA, dataWithExtraB, dataWithExtraA],
82+
})
83+
).toEqual(undefined)
84+
})
85+
86+
it('should fail oneOf list with mixed results', () => {
87+
const dataWithExtraA = { name: 'test', description: 'hello', objType: 'a' }
88+
expect(
89+
validator.validateResponseBody('/one-of-example-list', 'get', '200', {
90+
items: [dataWithExtraA, { something: 'test', objType: 'a' }],
91+
})
92+
).toEqual([
93+
{
94+
code: 'Validation-required',
95+
source: { pointer: '#/components/schemas/TestRequestA/required', parameter: 'name' },
96+
status: 500,
97+
title: "must have required property 'name'",
98+
},
99+
{
100+
code: 'Validation-additionalProperties',
101+
source: { pointer: '#/components/schemas/TestRequestA/additionalProperties', parameter: 'something' },
102+
status: 500,
103+
title: 'must NOT have additional properties',
104+
},
105+
])
106+
})
107+
76108
it('should fail oneOf AB', () => {
77109
const dataWithExtra = { name: 'test', description: 'hello', objType: 'a', somethingElse: 1 }
78110
expect(validator.validateResponseBody('/one-of-example', 'get', '200', dataWithExtra)).toEqual([
79111
{
80112
code: 'Validation-additionalProperties',
81-
source: { pointer: '#/components/schemas/TestRequestA/additionalProperties' },
113+
source: { pointer: '#/components/schemas/TestRequestA/additionalProperties', parameter: 'somethingElse' },
82114
status: 500,
83115
title: 'must NOT have additional properties',
84116
},
@@ -102,7 +134,7 @@ describe('The api validator for the user api spec', () => {
102134
expect(validator.validateRequestBody('/all-of-example', 'post', dataWithExtra)).toEqual([
103135
{
104136
code: 'Validation-required',
105-
source: { pointer: '#/components/schemas/AllOfExample/required' },
137+
source: { pointer: '#/components/schemas/AllOfExample/required', parameter: 'somethingElse' },
106138
status: 400,
107139
title: "must have required property 'somethingElse'",
108140
},
@@ -331,4 +363,176 @@ describe('The api validator for the user api spec', () => {
331363
},
332364
})
333365
})
366+
367+
it('demonstrate multipart request issue. we should properly implement this at some point', () => {
368+
expect(
369+
validator.validateRequestBody(
370+
'/multipart',
371+
'post',
372+
`------WebKitFormBoundaryMFTG70c7i7lAFI6f
373+
Content-Disposition: form-data; name="file"; filename="blob"
374+
Content-Type: image/png
375+
376+
377+
------WebKitFormBoundaryMFTG70c7i7lAFI6f--
378+
`
379+
)
380+
).toEqual([{ code: 'Validation-unexpected-request-body', status: 400, title: 'A request body is not supported' }])
381+
})
382+
383+
it('should fail to validate user state without type', () => {
384+
expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { enabled: true })).toEqual([
385+
{ code: 'Validation-discriminator', source: { pointer: '#/discriminator' }, status: 400, title: 'tag "type" must be string' },
386+
])
387+
})
388+
389+
it('should validate user state 1', () => {
390+
expect(
391+
validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'coffeeCx', enabled: true, nullableTest: null })
392+
).toBeUndefined()
393+
})
394+
395+
it('should validate user state 2', () => {
396+
expect(
397+
validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', {
398+
type: 'userUploads',
399+
entries: [
400+
{
401+
id: '123',
402+
name: 'test',
403+
communityId: '123',
404+
status: 'ongoing',
405+
path: 'mycommunity/sub/dir',
406+
},
407+
],
408+
})
409+
).toBeUndefined()
410+
})
411+
412+
it('should fail to validate user state invalid path', () => {
413+
expect(
414+
validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', {
415+
type: 'userUploads',
416+
entries: [
417+
{
418+
id: '123',
419+
name: 'test',
420+
communityId: '123',
421+
status: 'ongoing',
422+
path: '../../',
423+
},
424+
],
425+
})
426+
).toEqual([
427+
{
428+
code: 'Validation-pattern',
429+
source: { pointer: '#/components/schemas/SafePath/pattern' },
430+
status: 400,
431+
title: 'must match pattern "(?=(^(?!.*\\.\\.\\/).+))(?=(^(?!.*\\/\\/).+))"',
432+
},
433+
])
434+
})
435+
436+
it('should validate min max for arrays', () => {
437+
expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2'] })).toBeUndefined()
438+
expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2', '3'] })).toBeUndefined()
439+
expect(
440+
validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2', '3', '4'] })
441+
).toBeUndefined()
442+
})
443+
444+
it('should fail to validate min max with empty array', () => {
445+
expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: [] })).toEqual([
446+
{
447+
code: 'Validation-minItems',
448+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/0/minItems' },
449+
status: 400,
450+
title: 'must NOT have fewer than 4 items',
451+
},
452+
{
453+
code: 'Validation-minItems',
454+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/1/minItems' },
455+
status: 400,
456+
title: 'must NOT have fewer than 3 items',
457+
},
458+
{
459+
code: 'Validation-minItems',
460+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/2/minItems' },
461+
status: 400,
462+
title: 'must NOT have fewer than 2 items',
463+
},
464+
{
465+
code: 'Validation-oneOf',
466+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf' },
467+
status: 400,
468+
title: 'must match exactly one schema in oneOf',
469+
},
470+
])
471+
})
472+
it('should fail to validate min max for arrays', () => {
473+
expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1'] })).toEqual([
474+
{
475+
code: 'Validation-minItems',
476+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/0/minItems' },
477+
status: 400,
478+
title: 'must NOT have fewer than 4 items',
479+
},
480+
{
481+
code: 'Validation-minItems',
482+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/1/minItems' },
483+
status: 400,
484+
title: 'must NOT have fewer than 3 items',
485+
},
486+
{
487+
code: 'Validation-minItems',
488+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/2/minItems' },
489+
status: 400,
490+
title: 'must NOT have fewer than 2 items',
491+
},
492+
{
493+
code: 'Validation-oneOf',
494+
source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf' },
495+
status: 400,
496+
title: 'must match exactly one schema in oneOf',
497+
},
498+
])
499+
})
500+
501+
it('should patch user state', () => {
502+
expect(
503+
validator.validateRequestBody('/users/{uid}/state/{sid}', 'patch', [
504+
{
505+
op: 'add',
506+
path: '/entries',
507+
value: {
508+
id: 'HoPnVO3QD1uHdzJXpqD2U',
509+
title: 'you ok?',
510+
date: '2024-04-04T18:50:54.419Z',
511+
messages: [
512+
{ role: 'user', content: 'you ok?' },
513+
{
514+
content:
515+
"As an artificial intelligence, I don't have feelings, but I'm functioning as expected. Thank you for asking! How can I assist you today?",
516+
role: 'assistant',
517+
},
518+
],
519+
},
520+
},
521+
])
522+
).toBeUndefined()
523+
})
524+
525+
it('should validate webhook response', () => {
526+
expect(
527+
validator.validateRequestBody('/webhooks/mytest/{provision}', 'post', {
528+
siteInfo: {
529+
url: 'https://bertelsmann.sharepoint.com/sites/bportal_bcp_dev_privatecommunitytopublic',
530+
groupId: '9767a68f-842f-4197-a588-5e639358281b',
531+
teamId: '9767a68f-842f-4197-a588-5e639358281b',
532+
},
533+
taskId: '88a4984e-cf31-4b46-b849-035eb76feaf5',
534+
stateBag: { contentEntryId: 'wNC62LREKjzmLnUnxPjHHW' },
535+
})
536+
).toBeUndefined()
537+
})
334538
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Primitive, unserializeParameters } from '../../src/openapi-validator'
2+
3+
describe('The api validator', () => {
4+
it.each([
5+
[
6+
{ filter: 'test', 'page[offset]': '0', 'page[limit]': '12' },
7+
{ filter: 'test', page: { offset: '0', limit: '12' } },
8+
],
9+
[{ 'page[one][two]': '12', 'page[one][three]': '123' }, { page: { one: { two: '12', three: '123' } } }],
10+
[
11+
{ 'another[one[two]]': 'test', 'page[[one][two]': '12', 'empty[]': '123' },
12+
{ another: { one: { two: 'test' } }, page: { '': { one: { two: '12' } } }, empty: { '': '123' } },
13+
],
14+
])('should unserialize parameters', (parameters: Record<string, Primitive>, expected: Record<string, any>) => {
15+
const actual = unserializeParameters(parameters)
16+
JSON.stringify(actual)
17+
expect(actual).toEqual(expected)
18+
})
19+
})

‎packages/azure-functions-openapi-validator/src/helper.ts

-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { HttpResponseInit, InvocationContext } from '@azure/functions'
22
import { HeadersInit } from 'undici'
33

44
export const STATUS_CODE_OK = 200
5-
export const STATUS_CODE_BAD_REQUEST = 400
6-
export const STATUS_CODE_INTERNAL_SERVER_ERROR = 500
75

86
export const createJsonResponse = (body: unknown, status = STATUS_CODE_OK, headers?: HeadersInit): HttpResponseInit => {
97
const finalHeaders: HeadersInit = headers ?? {}

‎packages/azure-functions-openapi-validator/src/validation-hook-setup.ts

+218-70
Large diffs are not rendered by default.

‎packages/azure-functions-openapi-validator/test/.eslintrc.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module.exports = {
88
extends: ["../.eslintrc.js"],
99
rules: {
1010
"@typescript-eslint/no-magic-numbers": "off",
11-
"no-console": "off"
11+
"@typescript-eslint/no-explicit-any": "off",
12+
"no-console": "off",
1213
}
1314
}

‎packages/azure-functions-openapi-validator/test/unit/validation-hook-setup.spec.ts

+222-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
1+
import { ReadableStream } from 'stream/web'
22
import { MockProxy, mock, mockReset } from 'jest-mock-extended'
33
import { AjvOpenApiValidator } from '@restfulhead/ajv-openapi-request-response-validator'
44
import { HttpRequest, HttpResponseInit, PostInvocationContext, PreInvocationContext } from '@azure/functions'
55
import {
66
ValidatorHookOptions,
77
configureValidationPostInvocationHandler,
88
configureValidationPreInvocationHandler,
9+
DEFAULT_HOOK_OPTIONS,
910
} from '../../src/validation-hook-setup'
1011

1112
describe('The app validator', () => {
@@ -191,7 +192,7 @@ describe('The app validator', () => {
191192
['code'],
192193
expect.anything()
193194
)
194-
expect(mockValidator.validateRequestBody).toHaveBeenCalledTimes(0)
195+
expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health', 'GET', undefined, true, expect.anything())
195196
})
196197

197198
it('should fail with query parameter validation error', async () => {
@@ -215,6 +216,65 @@ describe('The app validator', () => {
215216
expect(mockValidator.validateRequestBody).toHaveBeenCalledTimes(0)
216217
})
217218

219+
it('should fail with request body validation error', async () => {
220+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
221+
mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR])
222+
const handler = configureValidationPreInvocationHandler(mockValidator)
223+
const ctx = getMockPreContext('api/v1/health?something', JSON.stringify({ status: 'ok' }))
224+
await handler(ctx)
225+
226+
const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') }
227+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
228+
229+
expect(functionResult).toEqual({ status: 400, body: JSON.stringify({ errors: [MOCK_ERROR] }), headers: JSON_HEADERS })
230+
expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health?something', 'GET', undefined, true, expect.anything())
231+
})
232+
233+
it('should exlcude request body validation error', async () => {
234+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
235+
mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR])
236+
const handler = configureValidationPreInvocationHandler(mockValidator, {
237+
...DEFAULT_HOOK_OPTIONS,
238+
exclude: [
239+
{
240+
path: '/api/v1/health',
241+
method: 'GET',
242+
validation: false,
243+
},
244+
],
245+
})
246+
const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' }))
247+
await handler(ctx)
248+
249+
const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') }
250+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
251+
252+
expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' })
253+
expect(mockValidator.validateRequestBody).not.toHaveBeenCalled()
254+
})
255+
256+
it('should exlcude query validation', async () => {
257+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} })
258+
const handler = configureValidationPreInvocationHandler(mockValidator, {
259+
...DEFAULT_HOOK_OPTIONS,
260+
exclude: [
261+
{
262+
path: 'api/v1/health',
263+
method: 'GET',
264+
validation: false,
265+
},
266+
],
267+
})
268+
const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' }))
269+
await handler(ctx)
270+
271+
const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') }
272+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
273+
274+
expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' })
275+
expect(mockValidator.validateRequestBody).not.toHaveBeenCalled()
276+
})
277+
218278
it('should pass post request without body', async () => {
219279
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
220280
const handler = configureValidationPreInvocationHandler(mockValidator)
@@ -257,6 +317,44 @@ describe('The app validator', () => {
257317
expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/messages', 'POST', { hello: 'world' }, true, expect.anything())
258318
})
259319

320+
it('should fail missing request content type', async () => {
321+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
322+
const handler = configureValidationPreInvocationHandler(mockValidator)
323+
const ctx = getMockPreContext('api/v1/messages', JSON.stringify({ status: 'ok' }))
324+
await handler(ctx)
325+
326+
const request: HttpRequest = DEFAULT_HTTP_POST_REQUEST({ hello: 'world' })
327+
request.headers.delete('Content-Type')
328+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
329+
330+
expect(functionResult).toEqual({
331+
status: 400,
332+
body: '{"errors":[{"status":400,"code":"Validation-missing-content-type-header","title":"The request header \'Content-Type\' is missing"}]}',
333+
headers: { 'Content-Type': 'application/json' },
334+
})
335+
336+
expect(mockValidator.validateRequestBody).not.toHaveBeenCalled()
337+
})
338+
339+
it('should fail wrong request content type', async () => {
340+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
341+
const handler = configureValidationPreInvocationHandler(mockValidator)
342+
const ctx = getMockPreContext('api/v1/messages', JSON.stringify({ status: 'ok' }))
343+
await handler(ctx)
344+
345+
const request: HttpRequest = DEFAULT_HTTP_POST_REQUEST({ hello: 'world' })
346+
request.headers.set('Content-Type', 'text/plain')
347+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
348+
349+
expect(functionResult).toEqual({
350+
status: 400,
351+
body: '{"errors":[{"status":400,"code":"Validation-invalid-content-type-header","title":"The content type \'text/plain\' is not supported."}]}',
352+
headers: { 'Content-Type': 'application/json' },
353+
})
354+
355+
expect(mockValidator.validateRequestBody).not.toHaveBeenCalled()
356+
})
357+
260358
it('should fail with post request body validation error', async () => {
261359
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} })
262360
mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR])
@@ -305,10 +403,132 @@ describe('The app validator', () => {
305403

306404
expect(ctx.result).toEqual({
307405
status: 500,
406+
body: '{"errors":[{"status":500,"code":"Validation","title":"Response body validation failed"}]}',
407+
headers: {
408+
'Content-Type': 'application/json',
409+
},
410+
})
411+
expect(mockValidator.validateResponseBody).toHaveBeenCalledWith('/api/v1/health', 'GET', 200, { hello: 'ok' }, true, expect.anything())
412+
})
413+
414+
it('should pass with warning for non json response body', async () => {
415+
const handler = configureValidationPostInvocationHandler(mockValidator, {
416+
responseBodyValidationMode: { returnErrorResponse: false, strict: true, logLevel: 'info' },
417+
queryParameterValidationMode: false,
418+
requestBodyValidationMode: false,
419+
})
420+
const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, body: 'hello\nworld' })
421+
await handler(ctx)
422+
423+
expect(ctx.result).toEqual({ status: 200, body: 'hello\nworld' })
424+
expect(mockValidator.validateResponseBody).not.toHaveBeenCalled()
425+
})
426+
427+
it('should fail for non json response body', async () => {
428+
const handler = configureValidationPostInvocationHandler(mockValidator, withResponseValidation)
429+
const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, body: 'hello\nworld' })
430+
await handler(ctx)
431+
432+
expect(ctx.result).toEqual({
433+
status: 500,
434+
body: '{"errors":[{"status":500,"code":"Validation-invalid-content-type-header","title":"Response body validation failed"}]}',
435+
headers: {
436+
'Content-Type': 'application/json',
437+
},
438+
})
439+
expect(mockValidator.validateResponseBody).not.toHaveBeenCalled()
440+
})
441+
442+
it('should handle path request exclusions 1', async () => {
443+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} })
444+
mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR])
445+
const handler = configureValidationPreInvocationHandler(mockValidator, {
446+
...DEFAULT_HOOK_OPTIONS,
447+
exclude: [
448+
{
449+
path: '/api/v1/health',
450+
method: 'GET',
451+
validation: {
452+
queryParameter: false,
453+
requestBody: false,
454+
responseBody: true,
455+
},
456+
},
457+
],
458+
})
459+
const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' }))
460+
await handler(ctx)
461+
462+
const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') }
463+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
464+
465+
expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' })
466+
expect(mockValidator.validateRequestBody).not.toHaveBeenCalled()
467+
expect(mockValidator.validateQueryParams).not.toHaveBeenCalled()
468+
})
469+
470+
it('should handle path request exclusions2', async () => {
471+
mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} })
472+
mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR])
473+
const handler = configureValidationPreInvocationHandler(mockValidator, {
474+
...DEFAULT_HOOK_OPTIONS,
475+
exclude: [
476+
{
477+
path: '/api/v1/health',
478+
method: 'GET',
479+
validation: {
480+
queryParameter: false,
481+
requestBody: {
482+
returnErrorResponse: true,
483+
strict: false,
484+
logLevel: 'info',
485+
},
486+
responseBody: true,
487+
},
488+
},
489+
],
490+
})
491+
const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' }))
492+
await handler(ctx)
493+
494+
const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') }
495+
const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext)
496+
497+
expect(functionResult).toEqual({
308498
body: '{"errors":[{"status":400,"code":"ValidationError","title":"Validation failed"}]}',
309499
headers: {
310500
'Content-Type': 'application/json',
311501
},
502+
status: 400,
503+
})
504+
expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health', 'GET', undefined, false, expect.anything())
505+
expect(mockValidator.validateQueryParams).not.toHaveBeenCalled()
506+
})
507+
508+
it('should handle path response exclusions', async () => {
509+
mockValidator.validateResponseBody.mockReturnValueOnce([MOCK_ERROR])
510+
const handler = configureValidationPostInvocationHandler(mockValidator, {
511+
...DEFAULT_HOOK_OPTIONS,
512+
exclude: [
513+
{
514+
path: '/api/v1/health',
515+
method: 'GET',
516+
validation: {
517+
queryParameter: false,
518+
requestBody: false,
519+
responseBody: true,
520+
},
521+
},
522+
],
523+
})
524+
const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, jsonBody: { hello: 'ok' } })
525+
await handler(ctx)
526+
expect(ctx.result).toEqual({
527+
body: '{"errors":[{"status":500,"code":"Validation","title":"Response body validation failed"}]}',
528+
headers: {
529+
'Content-Type': 'application/json',
530+
},
531+
status: 500,
312532
})
313533
expect(mockValidator.validateResponseBody).toHaveBeenCalledWith('/api/v1/health', 'GET', 200, { hello: 'ok' }, true, expect.anything())
314534
})

0 commit comments

Comments
 (0)
Please sign in to comment.