Skip to content

Commit 6f0ff12

Browse files
fix: validation required warning with readonly writeonly props (#37)
Co-authored-by: Albina Blazhko <46962291+AlbinaBlazhko17@users.noreply.github.com>
1 parent 69a01a8 commit 6f0ff12

File tree

9 files changed

+229
-6
lines changed

9 files changed

+229
-6
lines changed

lib/vocabularies/code.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {CodeGen, _, and, or, not, nil, strConcat, getProperty, Code, Name} from
55
import {alwaysValidSchema, Type} from "../compile/util"
66
import N from "../compile/names"
77
import {useFunc} from "../compile/util"
8+
import {getSkipCondition} from "./oasContext"
89
export function checkReportMissingProp(cxt: KeywordCxt, prop: string): void {
910
const {gen, data, it} = cxt
1011
gen.if(noPropertyInData(gen, data, prop, it.opts.ownProperties), () => {
@@ -14,13 +15,17 @@ export function checkReportMissingProp(cxt: KeywordCxt, prop: string): void {
1415
}
1516

1617
export function checkMissingProp(
17-
{gen, data, it: {opts}}: KeywordCxt,
18+
{gen, data, it: {opts}, parentSchema}: KeywordCxt,
1819
properties: string[],
1920
missing: Name
2021
): Code {
2122
return or(
2223
...properties.map((prop) =>
23-
and(noPropertyInData(gen, data, prop, opts.ownProperties), _`${missing} = ${prop}`)
24+
and(
25+
not(getSkipCondition(parentSchema, prop) ?? _`false`),
26+
noPropertyInData(gen, data, prop, opts.ownProperties),
27+
_`${missing} = ${prop}`
28+
)
2429
)
2530
)
2631
}

lib/vocabularies/metadata.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@ export const metadataVocabulary: Vocabulary = [
55
"description",
66
"default",
77
"deprecated",
8-
"readOnly",
9-
"writeOnly",
8+
/**
9+
* readOnly/writeOnly are handled as validation keywords when OAS context is provided.
10+
* Keeping them here would register them as annotation-only metadata and would
11+
* prevent the context-aware validation behavior.
12+
*
13+
* @see ./validation/readOnly.ts
14+
* @see ./validation/writeOnly.ts
15+
*/
16+
// "readOnly",
17+
// "writeOnly",
1018
"examples",
1119
]
1220

lib/vocabularies/oasContext.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type {AnySchemaObject} from "../types"
2+
3+
import {_, or, type Code} from "../compile/codegen"
4+
import N from "../compile/names"
5+
6+
export function getSkipCondition(schema: AnySchemaObject, prop: string): Code | undefined {
7+
const propSchema = schema.properties?.[prop]
8+
if (!propSchema) return undefined
9+
10+
const hasReadOnly = propSchema.readOnly === true
11+
const hasWriteOnly = propSchema.writeOnly === true
12+
13+
if (!hasReadOnly && !hasWriteOnly) return undefined
14+
15+
const conditions: Code[] = []
16+
const oasContext = _`typeof ${N.this} == "object" && ${N.this} && ${N.this}.oas`
17+
18+
if (hasReadOnly) {
19+
conditions.push(_`${oasContext} && ${N.this}.oas.mode === "request"`)
20+
}
21+
22+
if (hasWriteOnly) {
23+
conditions.push(_`${oasContext} && ${N.this}.oas.mode === "response"`)
24+
}
25+
26+
return conditions.length === 1 ? conditions[0] : or(...conditions)
27+
}

lib/vocabularies/validation/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import limitLength from "./limitLength"
55
import pattern, {PatternError} from "./pattern"
66
import limitProperties from "./limitProperties"
77
import required, {RequiredError} from "./required"
8+
import readOnlyKeyword from "./readOnly"
9+
import writeOnlyKeyword from "./writeOnly"
810
import limitItems from "./limitItems"
911
import uniqueItems, {UniqueItemsError} from "./uniqueItems"
1012
import constKeyword, {ConstError} from "./const"
@@ -20,6 +22,8 @@ const validation: Vocabulary = [
2022
// object
2123
limitProperties,
2224
required,
25+
readOnlyKeyword,
26+
writeOnlyKeyword,
2327
// array
2428
limitItems,
2529
uniqueItems,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type {CodeKeywordDefinition, KeywordErrorDefinition} from "../../types"
2+
import type {KeywordCxt} from "../../compile/validate"
3+
import {_, str} from "../../compile/codegen"
4+
import N from "../../compile/names"
5+
6+
const error: KeywordErrorDefinition = {
7+
message: ({params}) =>
8+
str`must NOT be present in ${params.mode || "this context"}${
9+
params.location ? str` (${params.location})` : ""
10+
}`,
11+
params: ({params}) => _`{mode: ${params.mode}, location: ${params.location}}`,
12+
}
13+
14+
const def: CodeKeywordDefinition = {
15+
keyword: "readOnly",
16+
schemaType: "boolean",
17+
error,
18+
code(cxt: KeywordCxt) {
19+
if (cxt.schema !== true) return
20+
21+
const mode = _`(${N.this} && ${N.this}.oas ? ${N.this}.oas.mode : undefined)`
22+
const location = _`(${N.this} && ${N.this}.oas ? ${N.this}.oas.location : undefined)`
23+
cxt.setParams({mode, location})
24+
25+
cxt.fail(
26+
_`typeof ${N.this} == "object" && ${N.this} && ${N.this}.oas && ${N.this}.oas.mode === "request"`
27+
)
28+
},
29+
}
30+
31+
export default def

lib/vocabularies/validation/required.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
propertyInData,
88
noPropertyInData,
99
} from "../code"
10+
import {getSkipCondition} from "../oasContext"
1011
import {_, str, nil, not, Name, Code} from "../../compile/codegen"
1112
import {checkStrictMode} from "../../compile/util"
1213

@@ -52,7 +53,12 @@ const def: CodeKeywordDefinition = {
5253
cxt.block$data(nil, loopAllRequired)
5354
} else {
5455
for (const prop of schema) {
55-
checkReportMissingProp(cxt, prop)
56+
const skip = getSkipCondition(cxt.parentSchema, prop) ?? _`false`
57+
/**
58+
* Generate a runtime check: validate `required` only when this property
59+
* should NOT be skipped in the current context (readOnly/writeOnly).
60+
*/
61+
gen.if(not(skip), () => checkReportMissingProp(cxt, prop))
5662
}
5763
}
5864
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type {CodeKeywordDefinition, KeywordErrorDefinition} from "../../types"
2+
import type {KeywordCxt} from "../../compile/validate"
3+
import {_, str} from "../../compile/codegen"
4+
import N from "../../compile/names"
5+
6+
const error: KeywordErrorDefinition = {
7+
message: ({params}) =>
8+
str`must NOT be present in ${params.mode || "this context"}${
9+
params.location ? str` (${params.location})` : ""
10+
}`,
11+
params: ({params}) => _`{mode: ${params.mode}, location: ${params.location}}`,
12+
}
13+
14+
const def: CodeKeywordDefinition = {
15+
keyword: "writeOnly",
16+
schemaType: "boolean",
17+
error,
18+
code(cxt: KeywordCxt) {
19+
if (cxt.schema !== true) return
20+
const mode = _`(${N.this} && ${N.this}.oas ? ${N.this}.oas.mode : undefined)`
21+
const location = _`(${N.this} && ${N.this}.oas ? ${N.this}.oas.location : undefined)`
22+
cxt.setParams({mode, location})
23+
cxt.fail(
24+
_`typeof ${N.this} == "object" && ${N.this} && ${N.this}.oas && ${N.this}.oas.mode === "response"`
25+
)
26+
},
27+
}
28+
29+
export default def

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@redocly/ajv",
3-
"version": "8.17.2",
3+
"version": "8.17.3",
44
"description": "Another JSON Schema Validator",
55
"main": "dist/ajv.js",
66
"types": "dist/ajv.d.ts",

spec/validation/read-write.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type {DataValidationCxt} from "../../lib/types"
2+
import Ajv from "../ajv"
3+
import chai from "../chai"
4+
5+
chai.should()
6+
7+
type OasMode = "request" | "response"
8+
9+
function buildContext(data: unknown): DataValidationCxt {
10+
const dynamicAnchors: DataValidationCxt["dynamicAnchors"] = {}
11+
return {
12+
instancePath: "",
13+
parentData: {root: data},
14+
parentDataProperty: "root",
15+
rootData: data as Record<string, unknown>,
16+
dynamicAnchors,
17+
}
18+
}
19+
20+
function getAjv() {
21+
return new Ajv({passContext: true})
22+
}
23+
24+
function getOasContext(mode: OasMode, location?: "requestBody" | "responseBody") {
25+
return {oas: {mode, location}}
26+
}
27+
28+
type Validate = ReturnType<InstanceType<typeof Ajv>["compile"]>
29+
30+
function expectValid(
31+
validate: Validate,
32+
data: unknown,
33+
ctx?: {oas: {mode: OasMode; location?: "requestBody" | "responseBody"}}
34+
) {
35+
const valid = ctx ? validate.call(ctx, data, buildContext(data)) : validate(data)
36+
valid.should.equal(true)
37+
}
38+
39+
function expectInvalid(
40+
validate: Validate,
41+
data: unknown,
42+
keyword: "readOnly" | "writeOnly" | "required",
43+
ctx?: {oas: {mode: OasMode; location?: "requestBody" | "responseBody"}}
44+
) {
45+
const valid = ctx ? validate.call(ctx, data, buildContext(data)) : validate(data)
46+
valid.should.equal(false)
47+
validate.errors?.[0].keyword.should.equal(keyword)
48+
}
49+
50+
describe("readOnly/writeOnly", () => {
51+
const baseSchema = {
52+
type: "object",
53+
properties: {
54+
id: {type: "string", readOnly: true},
55+
password: {type: "string", writeOnly: true},
56+
},
57+
}
58+
59+
describe("default behavior without OAS context", () => {
60+
it("should not validate readOnly/writeOnly", () => {
61+
const validate = getAjv().compile(baseSchema)
62+
expectValid(validate, {id: "1", password: "secret"})
63+
expectValid(validate, {})
64+
})
65+
})
66+
67+
describe("with required", () => {
68+
const schemaWithRequired = {
69+
...baseSchema,
70+
required: ["id", "password"],
71+
}
72+
73+
it("should keep required without context", () => {
74+
const validate = getAjv().compile(schemaWithRequired)
75+
expectValid(validate, {id: "1", password: "secret"})
76+
expectInvalid(validate, {}, "required")
77+
})
78+
79+
it("should skip readOnly in request context and still require writeOnly", () => {
80+
const validate = getAjv().compile(schemaWithRequired)
81+
const requestCtx = getOasContext("request", "requestBody")
82+
83+
expectValid(validate, {password: "secret"}, requestCtx)
84+
expectInvalid(validate, {id: "1", password: "secret"}, "readOnly", requestCtx)
85+
expectInvalid(validate, {}, "required", requestCtx)
86+
})
87+
88+
it("should skip writeOnly in response context and still require readOnly", () => {
89+
const validate = getAjv().compile(schemaWithRequired)
90+
const responseCtx = getOasContext("response", "responseBody")
91+
92+
expectValid(validate, {id: "1"}, responseCtx)
93+
expectInvalid(validate, {id: "1", password: "secret"}, "writeOnly", responseCtx)
94+
expectInvalid(validate, {}, "required", responseCtx)
95+
})
96+
})
97+
98+
describe("presence validation with context", () => {
99+
it("should reject readOnly in request context", () => {
100+
const validate = getAjv().compile(baseSchema)
101+
const requestCtx = getOasContext("request", "requestBody")
102+
103+
expectInvalid(validate, {id: "1"}, "readOnly", requestCtx)
104+
})
105+
106+
it("should reject writeOnly in response context", () => {
107+
const validate = getAjv().compile(baseSchema)
108+
const responseCtx = getOasContext("response", "responseBody")
109+
110+
expectInvalid(validate, {password: "secret"}, "writeOnly", responseCtx)
111+
})
112+
})
113+
})

0 commit comments

Comments
 (0)