Skip to content

Commit 4007c75

Browse files
committed
JSON schema parsing strips extra properties
1 parent 3e004a8 commit 4007c75

File tree

4 files changed

+102
-22
lines changed

4 files changed

+102
-22
lines changed

packages/app/src/cli/services/generate/fetch-extension-specifications.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {MinimalAppIdentifiers} from '../../models/organization.js'
1010
import {unifiedConfigurationParserFactory} from '../../utilities/json-schema.js'
1111
import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array'
1212
import {outputDebug} from '@shopify/cli-kit/node/output'
13-
import {normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema'
13+
import {HandleInvalidAdditionalProperties, normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema'
1414

1515
interface FetchSpecificationsOptions {
1616
developerPlatformClient: DeveloperPlatformClient
@@ -75,7 +75,21 @@ async function mergeLocalAndRemoteSpecs(
7575
const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification &
7676
FlattenedRemoteSpecification
7777

78-
const parseConfigurationObject = await unifiedConfigurationParserFactory(merged)
78+
// If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice.
79+
let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties
80+
switch (merged.uidStrategy) {
81+
case 'uuid':
82+
handleInvalidAdditionalProperties = 'fail'
83+
break
84+
case 'single':
85+
handleInvalidAdditionalProperties = 'strip'
86+
break
87+
case 'dynamic':
88+
handleInvalidAdditionalProperties = 'fail'
89+
break
90+
}
91+
92+
const parseConfigurationObject = await unifiedConfigurationParserFactory(merged, handleInvalidAdditionalProperties)
7993

8094
return {
8195
...merged,

packages/app/src/cli/utilities/json-schema.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import {FlattenedRemoteSpecification} from '../api/graphql/extension_specificati
22
import {BaseConfigType} from '../models/extensions/schemas.js'
33
import {configWithoutFirstClassFields, RemoteAwareExtensionSpecification} from '../models/extensions/specification.js'
44
import {ParseConfigurationResult} from '@shopify/cli-kit/node/schema'
5-
import {jsonSchemaValidate, normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema'
5+
import {
6+
HandleInvalidAdditionalProperties,
7+
jsonSchemaValidate,
8+
normaliseJsonSchema,
9+
} from '@shopify/cli-kit/node/json-schema'
610
import {isEmpty} from '@shopify/cli-kit/common/object'
711
import {JsonMapType} from '@shopify/cli-kit/node/toml'
812

@@ -13,6 +17,7 @@ import {JsonMapType} from '@shopify/cli-kit/node/toml'
1317
*/
1418
export async function unifiedConfigurationParserFactory(
1519
merged: RemoteAwareExtensionSpecification & FlattenedRemoteSpecification,
20+
handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties = 'strip',
1621
) {
1722
const contractJsonSchema = merged.validationSchema?.jsonSchema
1823
if (contractJsonSchema === undefined || isEmpty(JSON.parse(contractJsonSchema))) {
@@ -30,7 +35,12 @@ export async function unifiedConfigurationParserFactory(
3035
const subjectForAjv = zodValidatedData ?? (config as JsonMapType)
3136

3237
const subjectForAjvWithoutFirstClassFields = configWithoutFirstClassFields(subjectForAjv)
33-
const jsonSchemaParse = jsonSchemaValidate(subjectForAjvWithoutFirstClassFields, contract, extensionIdentifier)
38+
const jsonSchemaParse = jsonSchemaValidate(
39+
subjectForAjvWithoutFirstClassFields,
40+
contract,
41+
handleInvalidAdditionalProperties,
42+
extensionIdentifier,
43+
)
3444

3545
// Finally, we de-duplicate the error set from both validations -- identical messages for identical paths are removed
3646
let errors = zodParse.errors || []
@@ -55,7 +65,7 @@ export async function unifiedConfigurationParserFactory(
5565
}
5666
return {
5767
state: 'ok',
58-
data: zodValidatedData as BaseConfigType,
68+
data: jsonSchemaParse.data as BaseConfigType,
5969
errors: undefined,
6070
}
6171
}

packages/cli-kit/src/public/node/json-schema.test.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('jsonSchemaValidate', () => {
5252
zod.object({foo: zod.number().max(99)}),
5353
{foo: 100},
5454
],
55-
])('matches the zod behaviour for %s', (name, contract, zodVersion, subject) => {
55+
])('matches the zod behaviour for %s', (_name, contract, zodVersion, subject) => {
5656
const zodParsed = zodVersion.safeParse(subject)
5757
expect(zodParsed.success).toBe(false)
5858
if (zodParsed.success) {
@@ -61,7 +61,7 @@ describe('jsonSchemaValidate', () => {
6161

6262
const zodErrors = zodParsed.error.errors.map((error) => ({path: error.path, message: error.message}))
6363

64-
const schemaParsed = jsonSchemaValidate(subject, contract, `test-${name}`)
64+
const schemaParsed = jsonSchemaValidate(subject, contract, 'strip')
6565
expect(schemaParsed.state).toBe('error')
6666
expect(schemaParsed.errors, `Converting ${JSON.stringify(schemaParsed.rawErrors)}`).toEqual(zodErrors)
6767
})
@@ -77,10 +77,39 @@ describe('jsonSchemaValidate', () => {
7777
},
7878
'x-taplo': {foo: 'bar'},
7979
}
80-
const schemaParsed = jsonSchemaValidate(subject, contract, 'test2')
80+
const schemaParsed = jsonSchemaValidate(subject, contract, 'strip')
8181
expect(schemaParsed.state).toBe('ok')
8282
})
8383

84+
test('removes additional properties', () => {
85+
const subject = {
86+
foo: 'bar',
87+
baz: 'qux',
88+
}
89+
const contract = {additionalProperties: false, type: 'object', properties: {foo: {type: 'string'}}}
90+
const schemaParsed = jsonSchemaValidate(subject, contract, 'strip')
91+
expect(schemaParsed.state).toBe('ok')
92+
expect(schemaParsed.data).toEqual({foo: 'bar'})
93+
// confirm we don't mutate input parameters
94+
expect(schemaParsed.data).not.toEqual(subject)
95+
})
96+
97+
test('fails on additional properties', () => {
98+
const subject = {
99+
foo: 'bar',
100+
baz: 'qux',
101+
}
102+
const contract = {additionalProperties: false, type: 'object', properties: {foo: {type: 'string'}}}
103+
const schemaParsed = jsonSchemaValidate(subject, contract, 'fail')
104+
expect(schemaParsed.state).toBe('error')
105+
expect(schemaParsed.errors).toEqual([
106+
{
107+
path: [],
108+
message: 'must NOT have additional properties',
109+
},
110+
])
111+
})
112+
84113
test('deals with a union mismatch with a preferred branch', () => {
85114
const subject = {
86115
root: {
@@ -121,7 +150,7 @@ describe('jsonSchemaValidate', () => {
121150
},
122151
},
123152
}
124-
const schemaParsed = jsonSchemaValidate(subject, contract, 'test3')
153+
const schemaParsed = jsonSchemaValidate(subject, contract, 'strip')
125154
expect(schemaParsed.state).toBe('error')
126155
expect(schemaParsed.errors).toEqual([
127156
{
@@ -175,7 +204,7 @@ describe('jsonSchemaValidate', () => {
175204
},
176205
},
177206
}
178-
const schemaParsed = jsonSchemaValidate(subject, contract, 'test4')
207+
const schemaParsed = jsonSchemaValidate(subject, contract, 'fail')
179208
expect(schemaParsed.state).toBe('error')
180209
expect(schemaParsed.errors).toEqual([
181210
{

packages/cli-kit/src/public/node/json-schema.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
import {ParseConfigurationResult} from './schema.js'
3+
import {randomUUID} from './crypto.js'
34
import {getPathValue} from '../common/object.js'
45
import {capitalize} from '../common/string.js'
56
import {Ajv, ErrorObject, SchemaObject, ValidateFunction} from 'ajv'
67
import $RefParser from '@apidevtools/json-schema-ref-parser'
8+
import cloneDeep from 'lodash/cloneDeep.js'
9+
10+
export type HandleInvalidAdditionalProperties = 'strip' | 'fail'
711

812
type AjvError = ErrorObject<string, {[key: string]: unknown}>
913

@@ -22,6 +26,29 @@ export async function normaliseJsonSchema(schema: string): Promise<SchemaObject>
2226
return parsedSchema
2327
}
2428

29+
function createAjvValidator(
30+
handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties,
31+
schema: SchemaObject,
32+
) {
33+
// allowUnionTypes: Allows types like `type: ["string", "number"]`
34+
// removeAdditional: Removes extraneous properties from the subject if `additionalProperties: false` is specified
35+
let removeAdditional
36+
switch (handleInvalidAdditionalProperties) {
37+
case 'strip':
38+
removeAdditional = true
39+
break
40+
case 'fail':
41+
removeAdditional = undefined
42+
break
43+
}
44+
const ajv = new Ajv({allowUnionTypes: true, removeAdditional})
45+
ajv.addKeyword('x-taplo')
46+
47+
const validator = ajv.compile(schema)
48+
49+
return validator
50+
}
51+
2552
const validatorsCache = new Map<string, ValidateFunction>()
2653

2754
/**
@@ -31,27 +58,29 @@ const validatorsCache = new Map<string, ValidateFunction>()
3158
*
3259
* @param subject - The object to validate.
3360
* @param schema - The JSON schema to validate against.
61+
* @param handleInvalidAdditionalProperties - Whether to strip or fail on invalid additional properties.
3462
* @param identifier - The identifier of the schema being validated, used to cache the validator.
3563
* @returns The result of the validation. If the state is 'error', the errors will be in a zod-like format.
3664
*/
3765
export function jsonSchemaValidate(
3866
subject: object,
3967
schema: SchemaObject,
40-
identifier: string,
68+
handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties,
69+
identifier?: string,
4170
): ParseConfigurationResult<unknown> & {rawErrors?: AjvError[]} {
42-
const ajv = new Ajv({allowUnionTypes: true})
71+
const subjectToModify = cloneDeep(subject)
4372

44-
ajv.addKeyword('x-taplo')
73+
const cacheKey = identifier ?? randomUUID()
4574

46-
const validator = validatorsCache.get(identifier) ?? ajv.compile(schema)
47-
validatorsCache.set(identifier, validator)
75+
const validator = validatorsCache.get(cacheKey) ?? createAjvValidator(handleInvalidAdditionalProperties, schema)
76+
validatorsCache.set(cacheKey, validator)
4877

49-
validator(subject)
78+
validator(subjectToModify)
5079

5180
// Errors from the contract are post-processed to be more zod-like and to deal with unions better
5281
let jsonSchemaErrors
5382
if (validator.errors && validator.errors.length > 0) {
54-
jsonSchemaErrors = convertJsonSchemaErrors(validator.errors, subject, schema)
83+
jsonSchemaErrors = convertJsonSchemaErrors(validator.errors, subjectToModify, schema)
5584
return {
5685
state: 'error',
5786
data: undefined,
@@ -61,7 +90,7 @@ export function jsonSchemaValidate(
6190
}
6291
return {
6392
state: 'ok',
64-
data: subject,
93+
data: subjectToModify,
6594
errors: undefined,
6695
rawErrors: undefined,
6796
}
@@ -162,8 +191,6 @@ function convertJsonSchemaErrors(rawErrors: AjvError[], subject: object, schema:
162191
* @returns A simplified list of errors.
163192
*/
164193
function simplifyUnionErrors(rawErrors: AjvError[], subject: object, schema: SchemaObject): AjvError[] {
165-
const ajv = new Ajv({allowUnionTypes: true})
166-
ajv.addKeyword('x-taplo')
167194
let errors = rawErrors
168195

169196
const resolvedUnionErrors = new Set()
@@ -191,7 +218,7 @@ function simplifyUnionErrors(rawErrors: AjvError[], subject: object, schema: Sch
191218
// we know that none of the union schemas are correct, but for each of them we can measure how wrong they are
192219
const correctValuesAndErrors = unionSchemas
193220
.map((candidateSchemaFromUnion: SchemaObject) => {
194-
const candidateSchemaValidator = ajv.compile(candidateSchemaFromUnion)
221+
const candidateSchemaValidator = createAjvValidator('fail', candidateSchemaFromUnion)
195222
candidateSchemaValidator(subjectValue)
196223

197224
let score = 0
@@ -202,7 +229,7 @@ function simplifyUnionErrors(rawErrors: AjvError[], subject: object, schema: Sch
202229
const subSchema = candidateSchemaFromUnion.properties[propertyName] as SchemaObject
203230
const subjectValueSlice = getPathValue(subjectValue, propertyName)
204231

205-
const subValidator = ajv.compile(subSchema)
232+
const subValidator = createAjvValidator('fail', subSchema)
206233
if (subValidator(subjectValueSlice)) {
207234
return acc + 1
208235
}

0 commit comments

Comments
 (0)