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
11 changes: 11 additions & 0 deletions .changeset/plenty-jokes-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@shopware/api-gen": minor
---

Enhanced OpenAPI schema override merging to properly handle conflicts between `$ref` and composition keywords (`oneOf`, `anyOf`, `allOf`, `not`). When merging overrides:

- Composition keywords now automatically remove conflicting `$ref` properties
- `$ref` overrides can replace composition keywords entirely
- Different composition keywords can replace each other (e.g., `allOf` → `oneOf`)

This ensures correct schema merging when using composition keywords in override files, preventing invalid OpenAPI schemas with conflicting `$ref` and composition keyword properties.
180 changes: 180 additions & 0 deletions packages/api-gen/src/patchJsonSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,186 @@ describe("patchJsonSchema", () => {
}
`);
});

it("should remove $ref and add composition keyword (oneOf)", async () => {
const { patchedSchema } = patchJsonSchema({
openApiSchema: json5.parse(`{
components: {
schemas: {
LineItem: {
properties: {
cover: {
"$ref": "#/components/schemas/Media"
}
},
},
},
},
}`),
jsonOverrides: json5.parse(`{
components: {
LineItem: {
properties: {
cover: {
oneOf: [
{
"$ref": "#/components/schemas/Media"
},
{
"$ref": "#/components/schemas/ProductMedia"
}
]
},
},
},
},
}`),
});

expect(patchedSchema).toMatchInlineSnapshot(`
{
"components": {
"schemas": {
"LineItem": {
"properties": {
"cover": {
"oneOf": [
{
"$ref": "#/components/schemas/Media",
},
{
"$ref": "#/components/schemas/ProductMedia",
},
],
},
},
},
},
},
"paths": {},
}
`);
});

it("should remove composition keywords from the original object when override uses $ref", async () => {
const { patchedSchema } = patchJsonSchema({
openApiSchema: json5.parse(`{
components: {
schemas: {
LineItem: {
properties: {
cover: {
oneOf: [
{
"$ref": "#/components/schemas/Media"
},
{
"$ref": "#/components/schemas/ProductMedia"
}
]
}
},
},
},
},
}`),
jsonOverrides: json5.parse(`{
components: {
LineItem: {
properties: {
cover: {
"$ref": "#/components/schemas/Media"
},
},
},
},
}`),
});

expect(patchedSchema).toMatchInlineSnapshot(`
{
"components": {
"schemas": {
"LineItem": {
"properties": {
"cover": {
"$ref": "#/components/schemas/Media",
},
},
},
},
},
"paths": {},
}
`);
});

it("should replace composition keywords with another composition keyword", async () => {
const { patchedSchema } = patchJsonSchema({
openApiSchema: json5.parse(`{
components: {
schemas: {
LineItem: {
properties: {
cover: {
allOf: [
{
"$ref": "#/components/schemas/Media"
},
{
"$ref": "#/components/schemas/ProductMedia"
}
]
}
},
},
},
},
}`),
jsonOverrides: json5.parse(`{
components: {
LineItem: {
properties: {
cover: {
"oneOf": [
{
"$ref": "#/components/schemas/Media",
},
{
"$ref": "#/components/schemas/ProductMedia",
},
],
},
},
},
},
}`),
});

expect(patchedSchema).toMatchInlineSnapshot(`
{
"components": {
"schemas": {
"LineItem": {
"properties": {
"cover": {
"oneOf": [
{
"$ref": "#/components/schemas/Media",
},
{
"$ref": "#/components/schemas/ProductMedia",
},
],
},
},
},
},
},
"paths": {},
}
`);
});
});

describe("paths", () => {
Expand Down
78 changes: 76 additions & 2 deletions packages/api-gen/src/patchJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,29 @@ export type OverridesSchema = {
};
};

const COMPOSITION_KEYWORDS = ["oneOf", "anyOf", "allOf", "not"] as const;

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function hasCompositionKeyword(obj: Record<string, unknown>): boolean {
return COMPOSITION_KEYWORDS.some((keyword) => keyword in obj);
}

function hasRef(obj: Record<string, unknown>): boolean {
return "$ref" in obj;
}

export const extendedDefu = createDefu((obj, key, value) => {
// Handle array merging: concat arrays but skip duplicates
if (Array.isArray(obj[key]) && Array.isArray(value)) {
// concat arrays but skip duplicates
// @ts-expect-error - we know that obj[key] is an array as we're inside this if statement
obj[key] = [...new Set([...obj[key], ...value])].sort();
return true;
}

// if there is no key in object, add it
// If there is no key in object, add it
if (obj[key] === undefined) {
obj[key] = extendedDefu(value, value);
}
Expand All @@ -39,6 +53,66 @@ export const extendedDefu = createDefu((obj, key, value) => {
return true;
}

// Handle conflicts between $ref and composition keywords
if (!isPlainObject(obj)) {
return false;
}

const objRecord = obj as Record<string, unknown>;

// Handle composition keyword replacement: if original has a different composition keyword or $ref,
// delete the old one(s) and the new composition keyword will be merged in
//
// Example 1 - Composition keyword replacing $ref:
// Original: { "$ref": "#/components/schemas/Media" }
// Override: { "oneOf": [{ "$ref": "#/components/schemas/Media" }, { "$ref": "#/components/schemas/ProductMedia" }] }
// Result: { "oneOf": [{ "$ref": "#/components/schemas/Media" }, { "$ref": "#/components/schemas/ProductMedia" }] }
//
// Example 2 - Different composition keyword:
// Original: { "oneOf": [{ "$ref": "#/components/schemas/Media" }] }
// Override: { "anyOf": [{ "$ref": "#/components/schemas/ProductMedia" }] }
// Result: { "anyOf": [{ "$ref": "#/components/schemas/ProductMedia" }] }
//
// Example 3 - Same composition keyword (replacement):
// Original: { "oneOf": [{ "$ref": "#/components/schemas/Media" }, { "$ref": "#/components/schemas/ProductMedia" }] }
// Override: { "oneOf": [{ "$ref": "#/components/schemas/Media" }] }
// Result: { "oneOf": [{ "$ref": "#/components/schemas/Media" }] }
if (
typeof key === "string" &&
COMPOSITION_KEYWORDS.includes(key as (typeof COMPOSITION_KEYWORDS)[number])
) {
// Remove all other composition keywords (different from the one being set)
for (const keyword of COMPOSITION_KEYWORDS) {
if (keyword !== key && keyword in objRecord) {
delete objRecord[keyword];
}
}
// Remove $ref if present (composition keywords take precedence)
if (hasRef(objRecord)) {
// biome-ignore lint/performance/noDelete: delete $ref
delete objRecord.$ref;
}
}

// Handle $ref replacing composition keywords: replace entire object with $ref
//
// Example:
// Original: { "oneOf": [{ "$ref": "#/components/schemas/Media" }, { "$ref": "#/components/schemas/ProductMedia" }] }
// Override: { "$ref": "#/components/schemas/Media" }
// Result: { "$ref": "#/components/schemas/Media" }
if (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference here? from previous condition? I'd love to see test scenarios for it

isPlainObject(value) &&
hasRef(value) &&
typeof key === "string" &&
key in objRecord
) {
const originalValue = objRecord[key];
if (isPlainObject(originalValue) && hasCompositionKeyword(originalValue)) {
objRecord[key] = value;
return true;
}
}

return false;
});

Expand Down
Loading