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
245 changes: 245 additions & 0 deletions src/cli/runtime/body-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,251 @@ describe("generateBodyFlags", () => {

expect(flags[0]?.description).toBe("User email address");
});

test("merges top-level allOf into flat properties", () => {
const flags = generateBodyFlags(
{
allOf: [
{
type: "object",
properties: {
name: { type: "string", description: "Name" },
},
required: ["name"],
},
{
type: "object",
properties: {
email: { type: "string", description: "Email" },
},
},
],
},
new Set(),
);

expect(flags).toHaveLength(2);
expect(flags.find((f) => f.flag === "--name")).toEqual({
flag: "--name",
path: ["name"],
type: "string",
description: "Name",
required: true,
});
expect(flags.find((f) => f.flag === "--email")).toEqual({
flag: "--email",
path: ["email"],
type: "string",
description: "Email",
required: false,
});
});

test("merges nested allOf in property schemas", () => {
const flags = generateBodyFlags(
{
type: "object",
properties: {
transaction: {
allOf: [
{ type: "object" },
{
type: "object",
properties: {
payee_name: {
type: "string",
description: "The payee name",
},
amount: {
type: "integer",
description: "Amount in milliunits",
},
},
},
],
},
},
},
new Set(),
);

expect(flags).toHaveLength(2);
expect(flags.find((f) => f.flag === "--transaction.payee_name")).toEqual({
flag: "--transaction.payee_name",
path: ["transaction", "payee_name"],
type: "string",
description: "The payee name",
required: false,
});
expect(flags.find((f) => f.flag === "--transaction.amount")).toEqual({
flag: "--transaction.amount",
path: ["transaction", "amount"],
type: "integer",
description: "Amount in milliunits",
required: false,
});
});

test("handles OpenAPI 3.1 nullable types (type arrays)", () => {
const flags = generateBodyFlags(
{
type: "object",
properties: {
name: { type: "string", description: "Name" },
payee_name: {
type: ["string", "null"],
description: "Payee name",
},
memo: { type: ["string", "null"], description: "Memo" },
amount: { type: "integer", description: "Amount" },
category_id: {
type: ["string", "null"],
description: "Category",
},
},
},
new Set(),
);

expect(flags).toHaveLength(5);
expect(flags.find((f) => f.flag === "--name")?.type).toBe("string");
expect(flags.find((f) => f.flag === "--payee_name")?.type).toBe("string");
expect(flags.find((f) => f.flag === "--memo")?.type).toBe("string");
expect(flags.find((f) => f.flag === "--amount")?.type).toBe("integer");
expect(flags.find((f) => f.flag === "--category_id")?.type).toBe("string");
});

test("handles nullable types in nested allOf schemas", () => {
const flags = generateBodyFlags(
{
type: "object",
properties: {
transaction: {
allOf: [
{ type: "object" },
{
type: "object",
properties: {
account_id: { type: "string" },
payee_name: {
type: ["string", "null"],
description: "The payee name",
},
memo: { type: ["string", "null"] },
},
},
],
},
},
},
new Set(),
);

expect(flags).toHaveLength(3);
expect(
flags.find((f) => f.flag === "--transaction.account_id"),
).toBeDefined();
expect(
flags.find((f) => f.flag === "--transaction.payee_name"),
).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.memo")).toBeDefined();
});

test("reproduces YNAB PutTransactionWrapper schema (allOf + nullable types)", () => {
// This matches the dereferenced shape of YNAB's PUT /budgets/{id}/transactions/{id}
// request body: PutTransactionWrapper -> transaction: ExistingTransaction (allOf)
// -> SaveTransactionWithOptionalFields (mixed scalar and nullable types)
const schema = {
type: "object",
required: ["transaction"],
properties: {
transaction: {
allOf: [
{ type: "object" },
{
type: "object",
properties: {
account_id: { type: "string" },
date: { type: "string", description: "Transaction date" },
amount: {
type: "integer",
description: "Amount in milliunits",
},
payee_id: { type: ["string", "null"], description: "Payee ID" },
payee_name: {
type: ["string", "null"],
description: "Payee name",
},
category_id: {
type: ["string", "null"],
description: "Category ID",
},
memo: { type: ["string", "null"], description: "Memo" },
cleared: { type: "string", description: "Cleared status" },
approved: { type: "boolean", description: "Approved" },
flag_color: {
type: ["string", "null"],
description: "Flag color",
},
subtransactions: { type: "array" },
},
},
],
},
},
};

const flags = generateBodyFlags(schema, new Set());

// Should generate flags for all scalar/nullable fields (not subtransactions array)
expect(flags).toHaveLength(10);

// Scalar types
expect(flags.find((f) => f.flag === "--transaction.account_id")).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.date")).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.amount")?.type).toBe(
"integer",
);
expect(flags.find((f) => f.flag === "--transaction.cleared")).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.approved")?.type).toBe(
"boolean",
);

// Nullable types — these were previously missing
expect(flags.find((f) => f.flag === "--transaction.payee_id")).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.payee_name")).toBeDefined();
expect(
flags.find((f) => f.flag === "--transaction.category_id"),
).toBeDefined();
expect(flags.find((f) => f.flag === "--transaction.memo")).toBeDefined();
expect(
flags.find((f) => f.flag === "--transaction.flag_color"),
).toBeDefined();
});

test("handles allOf with properties alongside", () => {
const flags = generateBodyFlags(
{
type: "object",
properties: {
id: { type: "string" },
},
allOf: [
{
type: "object",
properties: {
name: { type: "string" },
},
},
],
},
new Set(),
);

expect(flags).toHaveLength(2);
expect(flags.find((f) => f.flag === "--id")).toBeDefined();
expect(flags.find((f) => f.flag === "--name")).toBeDefined();
});
});

describe("parseDotNotationFlags", () => {
Expand Down
70 changes: 61 additions & 9 deletions src/cli/runtime/body-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
*/

type JsonSchema = {
type?: string;
type?: string | string[];
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
required?: string[];
description?: string;
allOf?: JsonSchema[];
};

/**
* Resolve OpenAPI 3.1 nullable type arrays (e.g. ["string", "null"])
* to their non-null scalar type. Returns the raw type for simple strings.
*/
function resolveType(type: string | string[] | undefined): string | undefined {
if (Array.isArray(type)) {
return type.find((t) => t !== "null");
}
return type;
}

export type BodyFlagDef = {
flag: string; // e.g. "--name" or "--address.street"
path: string[]; // e.g. ["name"] or ["address", "street"]
Expand All @@ -21,22 +33,61 @@ export type BodyFlagDef = {
required: boolean;
};

/**
* Merge allOf compositions into a single schema with combined properties.
* Recursively handles nested allOf.
*/
function flattenAllOf(schema: JsonSchema): JsonSchema {
if (!schema.allOf || !Array.isArray(schema.allOf)) return schema;

const merged: JsonSchema = {
type: schema.type ?? "object",
properties: {},
required: [],
};

for (const sub of schema.allOf) {
const flat = flattenAllOf(sub);
if (flat.properties) {
merged.properties = { ...merged.properties, ...flat.properties };
}
if (flat.required) {
merged.required = [...(merged.required ?? []), ...flat.required];
}
}

// Preserve top-level fields from the original schema
if (schema.properties) {
merged.properties = { ...merged.properties, ...schema.properties };
}
if (schema.required) {
merged.required = [...(merged.required ?? []), ...schema.required];
}
if (schema.description) merged.description = schema.description;

return merged;
}

/**
* Generate flag definitions from a JSON schema.
* Recursively handles nested objects using dot notation.
* Merges allOf compositions before generating flags.
*/
export function generateBodyFlags(
schema: JsonSchema | undefined,
reservedFlags: Set<string>,
): BodyFlagDef[] {
if (!schema || schema.type !== "object" || !schema.properties) {
if (!schema) return [];

const resolved = flattenAllOf(schema);
if (resolveType(resolved.type) !== "object" || !resolved.properties) {
return [];
}

const flags: BodyFlagDef[] = [];
const requiredSet = new Set(schema.required ?? []);
const requiredSet = new Set(resolved.required ?? []);

collectFlags(schema.properties, [], requiredSet, flags, reservedFlags);
collectFlags(resolved.properties, [], requiredSet, flags, reservedFlags);

return flags;
}
Expand All @@ -58,13 +109,14 @@ function collectFlags(
// Skip if this flag would conflict with an operation parameter
if (reservedFlags.has(flagName)) continue;

const t = propSchema.type;
const resolved = flattenAllOf(propSchema);
const t = resolveType(resolved.type);

if (t === "object" && propSchema.properties) {
if (t === "object" && resolved.properties) {
// Recurse into nested object
const nestedRequired = new Set(propSchema.required ?? []);
const nestedRequired = new Set(resolved.required ?? []);
collectFlags(
propSchema.properties,
resolved.properties,
path,
nestedRequired,
out,
Expand All @@ -84,7 +136,7 @@ function collectFlags(
flag: flagName,
path,
type: t,
description: propSchema.description ?? `Body field '${path.join(".")}'`,
description: resolved.description ?? `Body field '${path.join(".")}'`,
required: isRequired,
});
}
Expand Down
Loading