diff --git a/CHANGELOG.md b/CHANGELOG.md index eb025674..eb5a0671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed +- Add @coerce Schema directive for surgical per-field scalar coercion + ## [2.175.1] - 2026-05-05 ### Changed diff --git a/graphql/schema.graphql b/graphql/schema.graphql index efd26d51..bd7ddd5f 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -4,6 +4,7 @@ directive @withSegment on FIELD_DEFINITION directive @withOrderFormId on FIELD_DEFINITION directive @withCurrentProfile on FIELD_DEFINITION directive @toVtexAssets on FIELD_DEFINITION +directive @coerce on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION type Query { """ @@ -245,7 +246,7 @@ type Query { """ Category id """ - id: Int + id: Int @coerce ): Category @cacheControl(scope: SEGMENT, maxAge: MEDIUM) """ @@ -255,7 +256,7 @@ type Query { """ Category tree level. Default: 3 """ - treeLevel: Int = 3 + treeLevel: Int = 3 @coerce ): [Category] @cacheControl(scope: SEGMENT, maxAge: MEDIUM) """ @@ -265,7 +266,7 @@ type Query { """ Brand id """ - id: Int + id: Int @coerce ): Brand @cacheControl(scope: PUBLIC, maxAge: MEDIUM) """ @@ -288,7 +289,7 @@ type Query { """ geoCoordinates for freight calculator """ - geoCoordinates: [String] + geoCoordinates: [String] @coerce """ Country of postal code """ @@ -499,30 +500,30 @@ type Query { """ Desired latitude coordinate, for example: -22.944370 """ - lat: String! + lat: String! @coerce """ Desired longitude coordinate, for example: -43.182553 """ - long: String! + long: String! @coerce """ max distance of pickup points from given coordinates, in kilometers, default value 50 """ maxDistance: Int = 50 ): NearPickupPointQueryResponse skuPickupSLAs( - itemId: String - seller: String - lat: String - long: String + itemId: String @coerce + seller: String @coerce + lat: String @coerce + long: String @coerce country: String ): [CheckoutSLA] skuPickupSLA( - itemId: String - seller: String - lat: String - long: String + itemId: String @coerce + seller: String @coerce + lat: String @coerce + long: String @coerce country: String - pickupId: String + pickupId: String @coerce ): CheckoutSLA """ Get pickup point by its id @@ -771,7 +772,7 @@ type Mutation { @deprecated( reason: "Field is no longer needed. Checkout cookie is automatically taken into account now" ) - itemId: String + itemId: String @coerce assemblyOptionsId: String options: [AssemblyOptionInput] ): OrderForm @withOrderFormId @withOwnerId diff --git a/graphql/types/OrderForm.graphql b/graphql/types/OrderForm.graphql index 28577e07..315f5b94 100644 --- a/graphql/types/OrderForm.graphql +++ b/graphql/types/OrderForm.graphql @@ -186,27 +186,27 @@ type OrderFormShippingData { scalar InputValues input OrderFormItemInput { - id: Int - index: Int - quantity: Int + id: Int @coerce + index: Int @coerce + quantity: Int @coerce seller: ID inputValues: InputValues options: [AssemblyOptionInput] } input OrderFormAddressInput { - addressId: String + addressId: String @coerce addressType: String - postalCode: String + postalCode: String @coerce country: String receiverName: String city: String state: String street: String - number: String + number: String @coerce complement: String neighborhood: String - geoCoordinates: [Float] + geoCoordinates: [Float] @coerce isDisposable: Boolean } diff --git a/graphql/types/Product.graphql b/graphql/types/Product.graphql index b360396f..1788a901 100644 --- a/graphql/types/Product.graphql +++ b/graphql/types/Product.graphql @@ -371,10 +371,10 @@ enum CrossSelingInputEnum { } input ItemInput { - itemId: ID + itemId: ID @coerce sellers: [SellerInput] } input SellerInput { - sellerId: ID + sellerId: ID @coerce } diff --git a/graphql/types/Profile.graphql b/graphql/types/Profile.graphql index 787d5826..a695fac5 100644 --- a/graphql/types/Profile.graphql +++ b/graphql/types/Profile.graphql @@ -264,10 +264,12 @@ input AddressInput { neighborhood: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) country: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) state: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) - number: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) + number: String @coerce @sanitize(allowHTMLTags: false, stripIgnoreTag: true) street: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) - geoCoordinates: [Float] - postalCode: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) + geoCoordinates: [Float] @coerce + postalCode: String + @coerce + @sanitize(allowHTMLTags: false, stripIgnoreTag: true) city: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) reference: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) addressName: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) diff --git a/graphql/types/Shipping.graphql b/graphql/types/Shipping.graphql index 6224741f..e8bf98c3 100644 --- a/graphql/types/Shipping.graphql +++ b/graphql/types/Shipping.graphql @@ -87,7 +87,7 @@ type LogisticsItem { } input ShippingItem { - id: String - quantity: String - seller: String + id: String @coerce + quantity: String @coerce + seller: String @coerce } diff --git a/node/__tests__/directives/coerce.test.ts b/node/__tests__/directives/coerce.test.ts new file mode 100644 index 00000000..42c76059 --- /dev/null +++ b/node/__tests__/directives/coerce.test.ts @@ -0,0 +1,296 @@ +import { Coerce } from '../../directives/coerce' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a duck-typed scalar mock (same shape graphql 14.x scalars expose). + * applyCoercion uses duck-typing instead of instanceof, so this is enough. + */ +function mockScalar(name: string) { + return { + name, + serialize: (v: any) => v, + parseValue: (v: any) => v, + parseLiteral: (ast: any) => ast.value, + } +} + +/** Wraps a type in a NonNull-like shell (has .ofType, no .parseValue). */ +function nonNull(type: any) { + return { ofType: type } +} + +/** Wraps a type in a List-like shell. */ +function list(type: any) { + return { ofType: type } +} + +/** + * Instantiates Coerce without going through the protected SchemaDirectiveVisitor + * constructor — applyCoercion is stateless (no this.schema access), so the + * instance state does not matter for these tests. + */ +function createCoerce(): Coerce { + return Object.create(Coerce.prototype) as Coerce +} + +function applyToField(type: any) { + createCoerce().visitInputFieldDefinition({ type } as any) + + return type +} + +function applyToArg(type: any) { + createCoerce().visitArgumentDefinition({ type } as any) + + return type +} + +// --------------------------------------------------------------------------- +// Visitor wiring +// --------------------------------------------------------------------------- + +describe('Coerce.visitInputFieldDefinition', () => { + it('patches parseValue and parseLiteral on the scalar type', () => { + const type = mockScalar('Int') + const originalParseValue = type.parseValue + const originalParseLiteral = type.parseLiteral + + applyToField(type) + + expect(type.parseValue).not.toBe(originalParseValue) + expect(type.parseLiteral).not.toBe(originalParseLiteral) + }) + + it('does nothing to types without parseValue (non-scalars)', () => { + const inputObjectType = { name: 'ShippingItem', getFields: () => ({}) } + + expect(() => applyToField(inputObjectType)).not.toThrow() + expect((inputObjectType as any).parseValue).toBeUndefined() + }) + + it('does nothing to scalars with unrecognised names', () => { + const idScalar = mockScalar('ID') + const original = idScalar.parseValue + + applyToField(idScalar) + + expect(idScalar.parseValue).toBe(original) + }) + + it('unwraps a NonNull wrapper and patches the inner type', () => { + const inner = mockScalar('Int') + const wrapped = nonNull(inner) + + applyToField(wrapped) + + expect(inner.parseValue('20')).toBe(20) + }) + + it('unwraps nested List → NonNull wrappers and patches the inner type', () => { + const inner = mockScalar('String') + const wrapped = list(nonNull(inner)) + + applyToField(wrapped) + + expect(inner.parseValue(42)).toBe('42') + }) +}) + +describe('Coerce.visitArgumentDefinition', () => { + it('patches the scalar type, same as visitInputFieldDefinition', () => { + const type = mockScalar('Float') + const original = type.parseValue + + applyToArg(type) + + expect(type.parseValue).not.toBe(original) + expect(type.parseValue('3.14')).toBe(3.14) + }) +}) + +// --------------------------------------------------------------------------- +// Int coercion +// --------------------------------------------------------------------------- + +describe('Int coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Int')) + }) + + it('accepts a string that represents a whole number', () => { + expect(type.parseValue('20')).toBe(20) + }) + + it('accepts a negative string integer', () => { + expect(type.parseValue('-5')).toBe(-5) + }) + + it('accepts a numeric integer', () => { + expect(type.parseValue(42)).toBe(42) + }) + + it('rejects a string float ("20.5")', () => { + expect(() => type.parseValue('20.5')).toThrow(/non-integer/) + }) + + it('rejects a numeric float (20.5)', () => { + expect(() => type.parseValue(20.5)).toThrow(/non-integer/) + }) + + it('rejects a non-numeric string', () => { + expect(() => type.parseValue('abc')).toThrow(/non-integer/) + }) + + it('rejects an empty string', () => { + expect(() => type.parseValue('')).toThrow(/non-integer/) + }) +}) + +describe('Int coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Int')) + }) + + it('accepts an IntValue AST node', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe(20) + }) + + it('accepts a StringValue AST node with a whole-number string', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '20' })).toBe(20) + }) + + it('rejects a StringValue AST node with a float string ("20.5")', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '20.5' })).toBeNull() + }) + + it('rejects a FloatValue AST node', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '20.5' })).toBeNull() + }) + + it('rejects an unrecognised AST kind', () => { + expect( + type.parseLiteral({ kind: 'BooleanValue', value: 'true' }) + ).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Float coercion +// --------------------------------------------------------------------------- + +describe('Float coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Float')) + }) + + it('accepts a string float ("3.14")', () => { + expect(type.parseValue('3.14')).toBe(3.14) + }) + + it('accepts a string integer ("20")', () => { + expect(type.parseValue('20')).toBe(20) + }) + + it('accepts a numeric float (3.14)', () => { + expect(type.parseValue(3.14)).toBe(3.14) + }) + + it('rejects a non-numeric string', () => { + expect(() => type.parseValue('abc')).toThrow(/non-numeric/) + }) +}) + +describe('Float coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Float')) + }) + + it('accepts a FloatValue AST node', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '3.14' })).toBe(3.14) + }) + + it('accepts an IntValue AST node', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe(20) + }) + + it('accepts a StringValue AST node with numeric content', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '3.14' })).toBe(3.14) + }) + + it('rejects a StringValue AST node with non-numeric content', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: 'abc' })).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// String coercion +// --------------------------------------------------------------------------- + +describe('String coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('String')) + }) + + it('accepts a number and converts it to string', () => { + expect(type.parseValue(20)).toBe('20') + }) + + it('accepts a float and converts it to string', () => { + expect(type.parseValue(3.14)).toBe('3.14') + }) + + it('accepts a boolean and converts it to string', () => { + expect(type.parseValue(true)).toBe('true') + }) + + it('passes a string through unchanged', () => { + expect(type.parseValue('hello')).toBe('hello') + }) +}) + +describe('String coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('String')) + }) + + it('accepts a StringValue AST node', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: 'hello' })).toBe( + 'hello' + ) + }) + + it('accepts an IntValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe('20') + }) + + it('accepts a FloatValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '3.14' })).toBe( + '3.14' + ) + }) + + it('accepts a BooleanValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'BooleanValue', value: 'true' })).toBe( + 'true' + ) + }) + + it('rejects an unrecognised AST kind', () => { + expect(type.parseLiteral({ kind: 'NullValue' })).toBeNull() + }) +}) diff --git a/node/directives/coerce.ts b/node/directives/coerce.ts new file mode 100644 index 00000000..d5096ca1 --- /dev/null +++ b/node/directives/coerce.ts @@ -0,0 +1,138 @@ +import { SchemaDirectiveVisitor } from 'graphql-tools' + +// Use string literals instead of importing Kind from graphql — the constants are +// stable across versions, but importing from graphql 0.13.x would fail the check +// because @vtex/api uses graphql 14.x internally with its own separate module copy. +const KIND = { + INT: 'IntValue', + FLOAT: 'FloatValue', + STRING: 'StringValue', + BOOLEAN: 'BooleanValue', +} + +const coercibleBehavior: Record< + string, + { parseValue(v: unknown): unknown; parseLiteral(ast: any): unknown } +> = { + Int: { + parseValue(value) { + if (typeof value === 'string' && value.trim() === '') { + throw new Error(`Int cannot represent non-integer value: ${value}`) + } + + const num = Number(value) + + if (!Number.isFinite(num) || !Number.isInteger(num)) { + throw new Error(`Int cannot represent non-integer value: ${value}`) + } + + return num + }, + parseLiteral(ast) { + if (ast.kind === KIND.INT) return parseInt(ast.value, 10) + if (ast.kind === KIND.STRING) { + const num = Number(ast.value) + + if (Number.isFinite(num) && Number.isInteger(num)) return num + } + + return null + }, + }, + + Float: { + parseValue(value) { + const parsed = parseFloat(String(value)) + + if (isNaN(parsed)) { + throw new Error(`Float cannot represent non-numeric value: ${value}`) + } + + return parsed + }, + parseLiteral(ast) { + if (ast.kind === KIND.FLOAT || ast.kind === KIND.INT) + return parseFloat(ast.value) + if (ast.kind === KIND.STRING) { + const parsed = parseFloat(ast.value) + + if (!isNaN(parsed)) return parsed + } + + return null + }, + }, + + String: { + parseValue(value) { + return String(value) + }, + parseLiteral(ast) { + if ( + ast.kind === KIND.STRING || + ast.kind === KIND.INT || + ast.kind === KIND.FLOAT || + ast.kind === KIND.BOOLEAN + ) { + return String(ast.value) + } + + return null + }, + }, +} + +// Duck-typing instead of instanceof: @vtex/api ships graphql 14.x in its own +// node_modules while the app declares graphql 0.13.x. The two module instances +// are different JS objects, so `instanceof GraphQLScalarType` (imported from +// 0.13.x) always returns false for types built by the 14.x schema builder. +function isScalarType(type: any): boolean { + return ( + type != null && + typeof type.parseValue === 'function' && + typeof type.serialize === 'function' && + typeof type.parseLiteral === 'function' + ) +} + +// Follow the ofType chain to unwrap NonNull/List without instanceof checks. +function unwrapType(type: any): any { + return type?.ofType != null ? unwrapType(type.ofType) : type +} + +function applyCoercion(fieldOrArg: any): void { + const baseType = unwrapType(fieldOrArg.type) + + if (!isScalarType(baseType)) return + + const behavior = coercibleBehavior[baseType.name] + + if (!behavior) return + + // Mutate parseValue/parseLiteral directly on the type object (which comes + // from graphql 14.x). healSchema only heals type REFERENCES (field.type + // pointers), not function properties on the type itself, so this mutation + // survives. The trade-off is that it affects all fields sharing this scalar + // type globally — acceptable since the goal is schema-wide coercion for the + // annotated scalar. + baseType.parseValue = behavior.parseValue + baseType.parseLiteral = behavior.parseLiteral +} + +/** + * Directive that applies lenient coercion to Int, Float, and String scalar + * types by patching their parseValue/parseLiteral functions in place. + * + * Works despite the graphql version mismatch between @vtex/api (14.x) and the + * app (0.13.x) because it uses duck-typing and in-place mutation instead of + * instanceof checks and type-object replacement. + */ +export class Coerce extends SchemaDirectiveVisitor { + public visitInputFieldDefinition(field: any) { + applyCoercion(field) + } + + public visitArgumentDefinition(argument: any) { + applyCoercion(argument) + } +} diff --git a/node/directives/index.ts b/node/directives/index.ts index f5c2fe79..a68c5b09 100644 --- a/node/directives/index.ts +++ b/node/directives/index.ts @@ -4,6 +4,7 @@ import { WithOrderFormId } from './withOrderFormId' import { ToVtexAssets } from './toVtexAssets' import { AuthorizationMetrics } from './authorizationMetrics' import { WithOwnerId } from './withOwnerId' +import { Coerce } from './coerce' export const schemaDirectives = { toVtexAssets: ToVtexAssets, @@ -12,4 +13,5 @@ export const schemaDirectives = { withOrderFormId: WithOrderFormId, withOwnerId: WithOwnerId, withAuthMetrics: AuthorizationMetrics, + coerce: Coerce, }