Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions configs/camunda-oca/regression-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8865,3 +8865,221 @@ describe.skipIf(CONFIG_NAME !== ACTIVE_CONFIG)(
});
},
);

// ---------------------------------------------------------------------------
// Enum-typed request-body field seeding (#338)
// ---------------------------------------------------------------------------
//
// The planner previously discarded enum constraints on required request-body
// fields: even though the extractor captured them, `buildRequestBodyFromCanonical`
// fell through to the seedBinding fallback and emitted `${fieldVar}` placeholders
// the universal seed prologue rewrote to random short strings. Servers reject
// those with HTTP 400 "Value <random> is not a valid <Enum>." Same for arrays
// whose items are enums (e.g. `permissionTypes: array of PermissionTypeEnum`):
// the synthesised element was a literal `"placeholder"` string the server
// rejected.
//
// This invariant is class-scoped (all required enum-typed request-body fields
// across the bundled spec — not just `ownerType` / `permissionTypes` from
// `createAuthorization` that triggered the bug report) so the same category
// of bug cannot recur in a sibling op.
describeForThisConfig(
'bundled-spec invariants: enum-typed request-body field seeding (#338)',
() => {
it('no feature or variant scenario seeds a ${...} placeholder or a "placeholder" array item for a required enum-typed request-body field', () => {
if (!existsSync(FEATURE_SCENARIOS_DIR)) {
throw new Error(
`Feature output directory not found at ${FEATURE_SCENARIOS_DIR}. Run 'npm run pipeline' first.`,
);
}
if (!existsSync(BUNDLED_SPEC_PATH)) {
throw new Error(
`Bundled spec not found at ${BUNDLED_SPEC_PATH}. Run 'npm run fetch-spec' first.`,
);
}

interface SchemaObject {
type?: string;
$ref?: string;
required?: string[];
properties?: Record<string, SchemaObject>;
items?: SchemaObject;
oneOf?: SchemaObject[];
allOf?: SchemaObject[];
enum?: unknown[];
}

interface OpenApiSpec {
paths: Record<
string,
Record<
string,
{
operationId?: string;
requestBody?: { content?: { 'application/json'?: { schema?: SchemaObject } } };
}
>
>;
components?: { schemas?: Record<string, SchemaObject> };
}

// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
const spec = JSON.parse(readFileSync(BUNDLED_SPEC_PATH, 'utf8')) as OpenApiSpec;

function resolveRef(ref: string): SchemaObject | undefined {
const name = ref.split('/').pop() ?? '';
return spec.components?.schemas?.[name];
}

function resolveSchemaObj(s: SchemaObject, depth = 0): SchemaObject {
if (depth > 20) return s;
if (s.$ref) {
const target = resolveRef(s.$ref);
if (target) return resolveSchemaObj(target, depth + 1);
}
return s;
}

/** Walk $ref + allOf chain to find an enum array. */
function effectiveEnum(s: SchemaObject, depth = 0): unknown[] | undefined {
if (depth > 20) return undefined;
const r = resolveSchemaObj(s, depth);
if (Array.isArray(r.enum)) return r.enum;
if (Array.isArray(r.allOf)) {
for (const part of r.allOf) {
const e = effectiveEnum(part, depth + 1);
if (e) return e;
}
}
return undefined;
}

/**
* Walk a request-body schema (root + any oneOf variants) and collect,
* for every top-level required field, the enum values declared on the
* field's scalar schema (`fieldEnums`) and on its items' scalar schema
* when the field is an array (`itemEnums`).
*/
function collectEnumFields(rootSchema: SchemaObject): {
fieldEnums: Map<string, unknown[]>;
itemEnums: Map<string, unknown[]>;
} {
const fieldEnums = new Map<string, unknown[]>();
const itemEnums = new Map<string, unknown[]>();
const branches: SchemaObject[] = [];
const root = resolveSchemaObj(rootSchema);
if (root.properties) branches.push(root);
for (const v of root.oneOf ?? []) branches.push(resolveSchemaObj(v));
for (const branch of branches) {
const required = new Set(branch.required ?? []);
for (const [field, sub] of Object.entries(branch.properties ?? {})) {
if (!required.has(field)) continue;
const fieldEnum = effectiveEnum(sub);
if (fieldEnum && fieldEnum.length > 0 && !fieldEnums.has(field)) {
fieldEnums.set(field, fieldEnum);
}
const resolved = resolveSchemaObj(sub);
if (resolved.type === 'array' && resolved.items) {
const itemEnum = effectiveEnum(resolved.items);
if (itemEnum && itemEnum.length > 0 && !itemEnums.has(field)) {
itemEnums.set(field, itemEnum);
}
}
}
}
return { fieldEnums, itemEnums };
Comment on lines +8929 to +8990
}

// Build per-op map: opId -> { fieldEnums, itemEnums }
const enumFieldsByOp = new Map<
string,
{ fieldEnums: Map<string, unknown[]>; itemEnums: Map<string, unknown[]> }
>();
for (const pathItem of Object.values(spec.paths ?? {})) {
for (const op of Object.values(pathItem)) {
if (!op.operationId) continue;
const jsonBody = op.requestBody?.content?.['application/json']?.schema;
if (!jsonBody) continue;
const collected = collectEnumFields(jsonBody);
if (collected.fieldEnums.size > 0 || collected.itemEnums.size > 0) {
enumFieldsByOp.set(op.operationId, collected);
}
}
}

interface FeatureScenarioFile {
scenarios: {
id: string;
requestPlan?: { operationId: string; bodyTemplate?: Record<string, unknown> }[];
}[];
}

const offenders: {
file: string;
scenario: string;
operationId: string;
field: string;
value: string;
expectedEnum: unknown[];
}[] = [];
const placeholderPattern = /^\$\{[^}]+\}$/;

for (const dir of [FEATURE_SCENARIOS_DIR, VARIANT_SCENARIOS_DIR]) {
if (!existsSync(dir)) continue;
for (const f of readdirSync(dir)) {
if (!f.endsWith('-scenarios.json')) continue;
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
const file = JSON.parse(readFileSync(join(dir, f), 'utf8')) as FeatureScenarioFile;
for (const scenario of file.scenarios ?? []) {
for (const step of scenario.requestPlan ?? []) {
const enums = enumFieldsByOp.get(step.operationId);
if (!enums) continue;
for (const [field, value] of Object.entries(step.bodyTemplate ?? {})) {
// Scalar enum field: a string that looks like ${var} is a placeholder seed.
const fieldEnum = enums.fieldEnums.get(field);
if (fieldEnum && typeof value === 'string' && placeholderPattern.test(value)) {
offenders.push({
file: f,
scenario: scenario.id,
operationId: step.operationId,
field,
value,
expectedEnum: fieldEnum,
});
continue;
}
// Array enum field: any element that's a ${var} placeholder OR
// the generic synthesised "placeholder" literal is wrong.
const itemEnum = enums.itemEnums.get(field);
if (itemEnum && Array.isArray(value)) {
for (const elem of value) {
const isPlaceholderVar =
typeof elem === 'string' && placeholderPattern.test(elem);
const isGenericPlaceholder = elem === 'placeholder';
if (isPlaceholderVar || isGenericPlaceholder) {
offenders.push({
file: f,
scenario: scenario.id,
operationId: step.operationId,
field,
value: JSON.stringify(elem),
expectedEnum: itemEnum,
});
}
Comment on lines +9017 to +9068
}
}
}
}
}
}
}

expect(
offenders,
'A scenario bodyTemplate seeds a placeholder for a required enum-typed request-body field. ' +
'The planner must emit the first enum value as an inline literal instead of ${var} or "placeholder". ' +
'Server validation rejects unknown enum values with HTTP 400 (#338).',
).toEqual([]);
});
},
);
39 changes: 38 additions & 1 deletion path-analyser/src/canonicalSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ export interface CanonicalNodeMeta {
type: string;
required: boolean;
semanticProvider?: string; // semantic type if x-semantic-provider: true
/**
* Enum values declared on this field's schema (after $ref + allOf
* resolution). Present for scalar leaf nodes whose schema constrains
* the value to a fixed set. Used by `buildRequestBodyFromCanonical`
* to emit an enum literal instead of seeding a `${var}` placeholder
* (#338).
*/
enum?: unknown[];
/**
* Enum values declared on this array node's item schema. Present for
* top-level array fields whose items are scalar enums (e.g.
* `permissionTypes: { type: 'array', items: { enum: […] } }`). Used
* by the array synthesiser to emit `[enum[0]]` instead of the generic
* `['placeholder']` element (#338).
*/
itemEnum?: unknown[];
}

export interface OperationCanonicalShapes {
Expand All @@ -29,6 +45,7 @@ interface SchemaObject {
anyOf?: SchemaObject[];
oneOf?: SchemaObject[];
'x-semantic-provider'?: boolean;
enum?: unknown[];
// Permissive escape hatch so the allOf merger can copy over arbitrary
// OpenAPI keywords (description, example, format, …) without per-key
// typing or unsafe casts.
Expand Down Expand Up @@ -178,6 +195,13 @@ function walkSchema(
if (type === 'array' && schema.items) {
const childPath = `${pathSoFar}[]`;
const childPointer = `${pointer}/items`;
// Capture an item-level enum (e.g. `permissionTypes: { type: 'array',
// items: { enum: […] } }`) so the array synthesiser can emit
// `[enum[0]]` instead of the generic `['placeholder']` element
// (#338). `schema.items` may itself be a $ref / allOf, so resolve
// it before reading `.enum`.
const resolvedItems = resolveSchema(schema.items, components);
const itemEnum = Array.isArray(resolvedItems.enum) ? resolvedItems.enum : undefined;
acc.push({
path: `${pathSoFar}[]`,
pointer: childPointer,
Expand All @@ -186,6 +210,7 @@ function walkSchema(
semanticProvider: schema['x-semantic-provider']
? inferSemanticTypeFromPath(pathSoFar)
: undefined,
itemEnum,
});
walkSchema(schema.items, childPointer, childPath, acc, seen, components, false, depth + 1);
return;
Expand All @@ -194,7 +219,19 @@ function walkSchema(
const semantic = schema['x-semantic-provider']
? inferSemanticTypeFromPath(pathSoFar)
: undefined;
acc.push({ path: pathSoFar, pointer, type, required, semanticProvider: semantic });
// Capture leaf enum (e.g. `ownerType: { enum: ['USER', …] }`) so
// `buildRequestBodyFromCanonical` can emit an enum literal instead
// of seeding a `${var}` placeholder (#338). `schema` at this point
// is already $ref/allOf-resolved by the recursive descent above.
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
acc.push({
path: pathSoFar,
pointer,
type,
required,
semanticProvider: semantic,
enum: enumValues,
});
}
}

Expand Down
44 changes: 44 additions & 0 deletions path-analyser/src/extractSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface JsonSchema {
nullable?: boolean;
discriminator?: { propertyName?: string; mapping?: Record<string, string> };
'x-polymorphic-schema'?: boolean;
enum?: unknown[];
[key: string]: unknown;
}

Expand Down Expand Up @@ -269,9 +270,20 @@ function findOneOfGroups(
const required = vs.required || [];
const optional = Object.keys(props).filter((k) => !required.includes(k));
const fieldTypes: Record<string, string> = {};
const fieldEnums: Record<string, unknown[]> = {};
const fieldItemEnums: Record<string, unknown[]> = {};
for (const [fname, fsch] of Object.entries(props)) {
const ft = effectiveType(fsch, components);
if (ft && ft !== 'unknown') fieldTypes[fname] = ft;
const enumValues = effectiveEnum(fsch, components);
if (enumValues && enumValues.length > 0) fieldEnums[fname] = enumValues;
if (ft === 'array') {
const itemSchema = resolveSchema(fsch, components).items;
if (itemSchema) {
const itemEnum = effectiveEnum(itemSchema, components);
if (itemEnum && itemEnum.length > 0) fieldItemEnums[fname] = itemEnum;
}
}
}
let discriminator: { field: string; value: string } | undefined;
if (resolved.discriminator?.propertyName) {
Expand All @@ -289,6 +301,8 @@ function findOneOfGroups(
required,
optional,
fieldTypes,
fieldEnums: Object.keys(fieldEnums).length > 0 ? fieldEnums : undefined,
fieldItemEnums: Object.keys(fieldItemEnums).length > 0 ? fieldItemEnums : undefined,
discriminator,
};
});
Expand Down Expand Up @@ -369,6 +383,36 @@ function effectiveType(schema: JsonSchema, components: Components): string {
return 'unknown';
}

/**
* Walk a (possibly $ref- or allOf-wrapped) schema and return its `enum`
* array if one is declared. Used by `findOneOfGroups` and `walkSchema` to
* surface enum constraints to the planner so it can emit a real enum
* literal instead of seeding a `${var}` placeholder (#338).
*
* Returns `undefined` when no enum is declared anywhere along the chain.
* The result is the raw `enum` array — callers pick the first value.
*/
Comment on lines +386 to +394
function effectiveEnum(
schema: JsonSchema | undefined,
components: Components,
depth = 0,
): unknown[] | undefined {
if (!schema || depth > 10) return undefined;
if (Array.isArray(schema.enum)) return schema.enum;
if (schema.$ref) {
const target = components[refName(schema.$ref)];
const e = effectiveEnum(target, components, depth + 1);
if (e) return e;
}
if (Array.isArray(schema.allOf)) {
for (const part of schema.allOf) {
const e = effectiveEnum(part, components, depth + 1);
if (e) return e;
}
}
return undefined;
}

function refName(ref: string): string {
return ref.split('/').pop() || ref;
}
Expand Down
Loading