Skip to content

Commit 43fc1e2

Browse files
committed
fix(cli): deep merge provided OpenAPI paths
1 parent 39696b1 commit 43fc1e2

File tree

7 files changed

+158
-14
lines changed

7 files changed

+158
-14
lines changed

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@
1919
"@typescript-eslint/parser": "^5.59.6",
2020
"eslint": "^8.40.0",
2121
"openapi-types": "^12.1.0"
22+
},
23+
"dependencies": {
24+
"just-intersect": "^4.3.0"
2225
}
2326
}

packages/cli/src/__fixtures__/widgets.fixtures.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export const mutations = {
119119
createWidget,
120120
};
121121

122-
export const paths = {
122+
export const widgetPaths = {
123123
"/mutations/createWidget": {
124124
post: createWidgetOAS,
125125
},

packages/cli/src/document-transformer.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import test from "node:test";
33
import {
44
components,
55
mutations,
6-
paths,
76
queries,
7+
widgetPaths,
88
} from "./__fixtures__/widgets.fixtures.js";
99
import { DocumentTransformer } from "./document-transformer.js";
1010
import { OASDocument, RPCDocument } from "./types";
@@ -23,6 +23,23 @@ const healthCheck = {
2323
},
2424
};
2525

26+
const conflictingPathName = Object.keys(
27+
widgetPaths
28+
)[0] as keyof typeof widgetPaths;
29+
const conflictingPathMethod = Object.keys(widgetPaths[conflictingPathName])[0];
30+
31+
const conflictingPathOperation = {
32+
operationId: "conflictingPathOperation",
33+
responses: {
34+
200: {
35+
description: "OK",
36+
},
37+
409: {
38+
description: "CONFLICT",
39+
},
40+
},
41+
};
42+
2643
const rpcDocument: RPCDocument = {
2744
yarpc: "1.0.0",
2845
info: {
@@ -44,7 +61,7 @@ const oasDocument: OASDocument = {
4461
info: rpcDocument.info,
4562
paths: {
4663
...healthCheck,
47-
...paths,
64+
...widgetPaths,
4865
},
4966
components: rpcDocument.components,
5067
};
@@ -56,4 +73,71 @@ test("DocumentTransformer#transform", async (t) => {
5673
const actual = await transformer.transform();
5774
assert.deepStrictEqual(actual, oasDocument);
5875
});
76+
77+
await t.test("throws an error if the YARPC version is missing", async () => {
78+
const doc = { ...rpcDocument };
79+
// @ts-expect-error Missing required yarpc version
80+
delete doc.yarpc;
81+
const transformer = new DocumentTransformer(doc);
82+
await assert.rejects(transformer.transform(), {
83+
message: "Missing required yarpc version",
84+
});
85+
});
86+
87+
await t.test(
88+
"throws an error if the YARPC version is not supported",
89+
async () => {
90+
const doc = { ...rpcDocument };
91+
// @ts-expect-error incorrect yarpc version
92+
doc.yarpc = "2.0.0";
93+
const transformer = new DocumentTransformer(doc);
94+
await assert.rejects(transformer.transform(), {
95+
message: "Unsupported YARPC version: 2.0.0",
96+
});
97+
}
98+
);
99+
100+
await t.test("merge same path different HTTP method", async () => {
101+
const doc = {
102+
...rpcDocument,
103+
paths: {
104+
...rpcDocument.paths,
105+
[conflictingPathName]: {
106+
patch: conflictingPathOperation,
107+
},
108+
},
109+
};
110+
111+
const expected = {
112+
...oasDocument,
113+
paths: {
114+
...oasDocument.paths,
115+
[conflictingPathName]: {
116+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
117+
...oasDocument.paths![conflictingPathName],
118+
patch: conflictingPathOperation,
119+
},
120+
},
121+
};
122+
123+
const transformer = new DocumentTransformer(doc);
124+
const actual = await transformer.transform();
125+
assert.deepStrictEqual(actual, expected);
126+
});
127+
128+
await t.test("merge same path+HTTP method", async () => {
129+
const doc = {
130+
...rpcDocument,
131+
paths: {
132+
...rpcDocument.paths,
133+
[conflictingPathName]: {
134+
[conflictingPathMethod]: conflictingPathOperation,
135+
},
136+
},
137+
};
138+
139+
const transformer = new DocumentTransformer(doc);
140+
const actual = await transformer.transform();
141+
assert.deepStrictEqual(actual, oasDocument);
142+
});
59143
});

packages/cli/src/document-transformer.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
1+
import intersect from "just-intersect";
12
import { OperationTransformer } from "./operation-transformer.js";
23
import { getDefaultResolver } from "./resolver.js";
3-
import { OASDocument, RPCDocument } from "./types/index.js";
4+
import {
5+
Logger,
6+
OASDocument,
7+
PathsObject,
8+
RPCDocument,
9+
} from "./types/index.js";
410

511
/**
612
* Transforms an RPC document into an OAS document
713
*/
814
export class DocumentTransformer {
915
private doc: RPCDocument;
1016
private transformer: OperationTransformer;
17+
private logger: Logger;
1118

1219
constructor(doc: RPCDocument) {
1320
this.doc = doc;
1421
this.transformer = new OperationTransformer({
1522
resolver: getDefaultResolver(doc),
1623
});
24+
this.logger = console;
1725
}
1826

1927
async transform(): Promise<OASDocument> {
20-
const { info, paths: oasPaths, operations, yarpc, ...rest } = this.doc;
28+
const { info, paths: oasPaths = {}, operations, yarpc, ...rest } = this.doc;
2129

2230
if (typeof yarpc !== "string") {
2331
throw new Error("Missing required yarpc version");
@@ -27,23 +35,20 @@ export class DocumentTransformer {
2735
throw new Error(`Unsupported YARPC version: ${String(yarpc)}`);
2836
}
2937

30-
const paths = await this.rpcToPaths(operations);
38+
const rpcPaths = await this.rpcToPaths(operations);
39+
40+
const paths = this.deepMergePaths(oasPaths, rpcPaths);
3141

3242
return {
3343
openapi: "3.1.0",
3444
info,
35-
paths: {
36-
...oasPaths,
37-
...paths,
38-
},
45+
paths,
3946
...rest,
4047
};
4148
}
4249

43-
async rpcToPaths(
44-
rpc: RPCDocument["operations"]
45-
): Promise<OASDocument["paths"]> {
46-
const paths: OASDocument["paths"] = {};
50+
async rpcToPaths(rpc: RPCDocument["operations"]): Promise<PathsObject> {
51+
const paths: PathsObject = {};
4752

4853
for (const [operationId, operation] of Object.entries(rpc.queries)) {
4954
paths[`/queries/${operationId}`] = {
@@ -65,4 +70,37 @@ export class DocumentTransformer {
6570

6671
return paths;
6772
}
73+
74+
deepMergePaths(paths: PathsObject, otherPaths: PathsObject): PathsObject {
75+
const mergedPaths: PathsObject = {
76+
...paths,
77+
};
78+
79+
Object.entries(otherPaths).forEach(([path, httpOperations]) => {
80+
// case 1: non-overlapping paths
81+
if (typeof mergedPaths[path] === "undefined") {
82+
mergedPaths[path] = httpOperations;
83+
return;
84+
}
85+
86+
// detect and report overlapping path+method
87+
const duplicate = intersect(
88+
Object.keys(httpOperations),
89+
Object.keys(mergedPaths[path])
90+
);
91+
if (duplicate.length > 0) {
92+
this.logger.warn(
93+
{ path, duplicate },
94+
"Duplicate operation(s) will be overwritten"
95+
);
96+
}
97+
98+
mergedPaths[path] = {
99+
...mergedPaths[path],
100+
...httpOperations,
101+
};
102+
});
103+
104+
return mergedPaths;
105+
}
68106
}

packages/cli/src/types/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
export * from "./oas.js";
22
export * from "./operations.js";
33
export * from "./service.js";
4+
5+
export type Logger = {
6+
debug: (...args: unknown[]) => void;
7+
warn: (...args: unknown[]) => void;
8+
};

packages/cli/src/types/oas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
MediaTypeObject,
1010
OperationObject,
1111
ParameterObject,
12+
PathsObject,
1213
ReferenceObject,
1314
ResponseObject,
1415
ResponsesObject,

0 commit comments

Comments
 (0)