History

trim sizes

Rhys Sullivan committed 8 months ago

Commit 71e33a91 parent a43695c30 files · +5421 −336Browse files

Showing the changes to packages/codemode-openapi/src/openapi-http-serialization.ts (1 of 30 files). Show all

Unified · Split

packages/codemode-openapi/src/openapi-http-serialization.ts+515−0
@@ -0,0 +1,515 @@
1+type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2+
3+export type OpenApiSerializableContent = {
4+ mediaType: string;
5+};
6+
7+export type OpenApiSerializableParameter = {
8+ name: string;
9+ location: OpenApiSerializableLocation;
10+ style?: string;
11+ explode?: boolean;
12+ allowReserved?: boolean;
13+ content?: ReadonlyArray<OpenApiSerializableContent>;
14+};
15+
16+export type OpenApiSerializableRequestBody = {
17+ contentTypes?: ReadonlyArray<string>;
18+ contents?: ReadonlyArray<OpenApiSerializableContent>;
19+};
20+
21+export type SerializedOpenApiQueryEntry = {
22+ name: string;
23+ value: string;
24+ allowReserved?: boolean;
25+};
26+
27+export type SerializedOpenApiParameterValue =
28+ | {
29+ kind: "path" | "header";
30+ value: string;
31+ }
32+ | {
33+ kind: "query";
34+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>;
35+ }
36+ | {
37+ kind: "cookie";
38+ pairs: ReadonlyArray<{
39+ name: string;
40+ value: string;
41+ }>;
42+ };
43+
44+const isRecord = (value: unknown): value is Record<string, unknown> =>
45+ value !== null && typeof value === "object" && !Array.isArray(value);
46+
47+const primitiveString = (value: unknown): string => {
48+ if (typeof value === "string") {
49+ return value;
50+ }
51+
52+ if (
53+ typeof value === "number"
54+ || typeof value === "boolean"
55+ || typeof value === "bigint"
56+ ) {
57+ return String(value);
58+ }
59+
60+ if (value === null || value === undefined) {
61+ return "";
62+ }
63+
64+ try {
65+ return JSON.stringify(value);
66+ } catch {
67+ return String(value);
68+ }
69+};
70+
71+const objectEntries = (value: unknown): Array<[string, unknown]> =>
72+ Object.entries(isRecord(value) ? value : {})
73+ .sort(([left], [right]) => left.localeCompare(right));
74+
75+const defaultStyle = (location: OpenApiSerializableLocation): string => {
76+ switch (location) {
77+ case "path":
78+ case "header":
79+ return "simple";
80+ case "cookie":
81+ case "query":
82+ return "form";
83+ }
84+};
85+
86+const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87+ location === "query" || location === "cookie"
88+ ? style === "form"
89+ : false;
90+
91+const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92+ parameter.style ?? defaultStyle(parameter.location);
93+
94+const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95+ parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96+
97+const encodePathPart = (value: unknown): string =>
98+ encodeURIComponent(primitiveString(value));
99+
100+const encodeQueryValue = (value: string, allowReserved: boolean): string => {
101+ const encoded = encodeURIComponent(value);
102+ if (!allowReserved) {
103+ return encoded;
104+ }
105+
106+ return encoded
107+ .replace(/%3A/gi, ":")
108+ .replace(/%2F/gi, "/")
109+ .replace(/%3F/gi, "?")
110+ .replace(/%23/gi, "#")
111+ .replace(/%5B/gi, "[")
112+ .replace(/%5D/gi, "]")
113+ .replace(/%40/gi, "@")
114+ .replace(/%21/gi, "!")
115+ .replace(/%24/gi, "$")
116+ .replace(/%26/gi, "&")
117+ .replace(/%27/gi, "'")
118+ .replace(/%28/gi, "(")
119+ .replace(/%29/gi, ")")
120+ .replace(/%2A/gi, "*")
121+ .replace(/%2B/gi, "+")
122+ .replace(/%2C/gi, ",")
123+ .replace(/%3B/gi, ";")
124+ .replace(/%3D/gi, "=");
125+};
126+
127+const preferredContentType = (input: {
128+ contents?: ReadonlyArray<OpenApiSerializableContent>;
129+ contentTypes?: ReadonlyArray<string>;
130+}): string | undefined => {
131+ const candidates = [
132+ ...(input.contents ?? []).map((content) => content.mediaType),
133+ ...(input.contentTypes ?? []),
134+ ];
135+ if (candidates.length === 0) {
136+ return undefined;
137+ }
138+
139+ return candidates.find((mediaType) => mediaType === "application/json")
140+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("+json"))
141+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("json"))
142+ ?? candidates[0];
143+};
144+
145+const serializeContentValue = (value: unknown, mediaType: string): string => {
146+ const normalized = mediaType.toLowerCase();
147+ if (
148+ normalized === "application/json"
149+ || normalized.includes("+json")
150+ || normalized.includes("json")
151+ ) {
152+ return JSON.stringify(value);
153+ }
154+
155+ if (normalized === "application/x-www-form-urlencoded") {
156+ const params = new URLSearchParams();
157+ for (const [key, entryValue] of objectEntries(value)) {
158+ if (Array.isArray(entryValue)) {
159+ for (const item of entryValue) {
160+ params.append(key, primitiveString(item));
161+ }
162+ continue;
163+ }
164+
165+ params.append(key, primitiveString(entryValue));
166+ }
167+ return params.toString();
168+ }
169+
170+ return primitiveString(value);
171+};
172+
173+const serializeQueryParameter = (
174+ parameter: OpenApiSerializableParameter,
175+ value: unknown,
176+): SerializedOpenApiParameterValue => {
177+ const style = selectedStyle(parameter);
178+ const explode = selectedExplode(parameter);
179+ const allowReserved = parameter.allowReserved === true;
180+ const contentType = preferredContentType({ contents: parameter.content });
181+
182+ if (contentType) {
183+ return {
184+ kind: "query",
185+ entries: [{
186+ name: parameter.name,
187+ value: serializeContentValue(value, contentType),
188+ allowReserved,
189+ }],
190+ };
191+ }
192+
193+ if (Array.isArray(value)) {
194+ if (style === "spaceDelimited") {
195+ return {
196+ kind: "query",
197+ entries: [{
198+ name: parameter.name,
199+ value: value.map((entry) => primitiveString(entry)).join(" "),
200+ allowReserved,
201+ }],
202+ };
203+ }
204+
205+ if (style === "pipeDelimited") {
206+ return {
207+ kind: "query",
208+ entries: [{
209+ name: parameter.name,
210+ value: value.map((entry) => primitiveString(entry)).join("|"),
211+ allowReserved,
212+ }],
213+ };
214+ }
215+
216+ return {
217+ kind: "query",
218+ entries:
219+ explode
220+ ? value.map((entry) => ({
221+ name: parameter.name,
222+ value: primitiveString(entry),
223+ allowReserved,
224+ }))
225+ : [{
226+ name: parameter.name,
227+ value: value.map((entry) => primitiveString(entry)).join(","),
228+ allowReserved,
229+ }],
230+ };
231+ }
232+
233+ if (isRecord(value)) {
234+ const entries = objectEntries(value);
235+
236+ if (style === "deepObject") {
237+ return {
238+ kind: "query",
239+ entries: entries.map(([key, entryValue]) => ({
240+ name: `${parameter.name}[${key}]`,
241+ value: primitiveString(entryValue),
242+ allowReserved,
243+ })),
244+ };
245+ }
246+
247+ return {
248+ kind: "query",
249+ entries:
250+ explode
251+ ? entries.map(([key, entryValue]) => ({
252+ name: key,
253+ value: primitiveString(entryValue),
254+ allowReserved,
255+ }))
256+ : [{
257+ name: parameter.name,
258+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
259+ allowReserved,
260+ }],
261+ };
262+ }
263+
264+ return {
265+ kind: "query",
266+ entries: [{
267+ name: parameter.name,
268+ value: primitiveString(value),
269+ allowReserved,
270+ }],
271+ };
272+};
273+
274+const serializeHeaderParameter = (
275+ parameter: OpenApiSerializableParameter,
276+ value: unknown,
277+): SerializedOpenApiParameterValue => {
278+ const explode = selectedExplode(parameter);
279+ const contentType = preferredContentType({ contents: parameter.content });
280+ if (contentType) {
281+ return {
282+ kind: "header",
283+ value: serializeContentValue(value, contentType),
284+ };
285+ }
286+
287+ if (Array.isArray(value)) {
288+ return {
289+ kind: "header",
290+ value: value.map((entry) => primitiveString(entry)).join(","),
291+ };
292+ }
293+
294+ if (isRecord(value)) {
295+ const entries = objectEntries(value);
296+ return {
297+ kind: "header",
298+ value:
299+ explode
300+ ? entries.map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`).join(",")
301+ : entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
302+ };
303+ }
304+
305+ return {
306+ kind: "header",
307+ value: primitiveString(value),
308+ };
309+};
310+
311+const serializeCookieParameter = (
312+ parameter: OpenApiSerializableParameter,
313+ value: unknown,
314+): SerializedOpenApiParameterValue => {
315+ const explode = selectedExplode(parameter);
316+ const contentType = preferredContentType({ contents: parameter.content });
317+ if (contentType) {
318+ return {
319+ kind: "cookie",
320+ pairs: [{
321+ name: parameter.name,
322+ value: serializeContentValue(value, contentType),
323+ }],
324+ };
325+ }
326+
327+ if (Array.isArray(value)) {
328+ return {
329+ kind: "cookie",
330+ pairs:
331+ explode
332+ ? value.map((entry) => ({
333+ name: parameter.name,
334+ value: primitiveString(entry),
335+ }))
336+ : [{
337+ name: parameter.name,
338+ value: value.map((entry) => primitiveString(entry)).join(","),
339+ }],
340+ };
341+ }
342+
343+ if (isRecord(value)) {
344+ const entries = objectEntries(value);
345+ return {
346+ kind: "cookie",
347+ pairs:
348+ explode
349+ ? entries.map(([key, entryValue]) => ({
350+ name: key,
351+ value: primitiveString(entryValue),
352+ }))
353+ : [{
354+ name: parameter.name,
355+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
356+ }],
357+ };
358+ }
359+
360+ return {
361+ kind: "cookie",
362+ pairs: [{
363+ name: parameter.name,
364+ value: primitiveString(value),
365+ }],
366+ };
367+};
368+
369+const serializePathParameter = (
370+ parameter: OpenApiSerializableParameter,
371+ value: unknown,
372+): SerializedOpenApiParameterValue => {
373+ const style = selectedStyle(parameter);
374+ const explode = selectedExplode(parameter);
375+ const contentType = preferredContentType({ contents: parameter.content });
376+ if (contentType) {
377+ return {
378+ kind: "path",
379+ value: encodePathPart(serializeContentValue(value, contentType)),
380+ };
381+ }
382+
383+ if (Array.isArray(value)) {
384+ if (style === "label") {
385+ return {
386+ kind: "path",
387+ value: `.${value.map((entry) => encodePathPart(entry)).join(explode ? "." : ",")}`,
388+ };
389+ }
390+
391+ if (style === "matrix") {
392+ return {
393+ kind: "path",
394+ value:
395+ explode
396+ ? value.map((entry) => `;${encodeURIComponent(parameter.name)}=${encodePathPart(entry)}`).join("")
397+ : `;${encodeURIComponent(parameter.name)}=${value.map((entry) => encodePathPart(entry)).join(",")}`,
398+ };
399+ }
400+
401+ return {
402+ kind: "path",
403+ value: value.map((entry) => encodePathPart(entry)).join(","),
404+ };
405+ }
406+
407+ if (isRecord(value)) {
408+ const entries = objectEntries(value);
409+
410+ if (style === "label") {
411+ return {
412+ kind: "path",
413+ value:
414+ explode
415+ ? `.${entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(".")}`
416+ : `.${entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(",")}`,
417+ };
418+ }
419+
420+ if (style === "matrix") {
421+ return {
422+ kind: "path",
423+ value:
424+ explode
425+ ? entries.map(([key, entryValue]) => `;${encodeURIComponent(key)}=${encodePathPart(entryValue)}`).join("")
426+ : `;${encodeURIComponent(parameter.name)}=${entries.flatMap(([key, entryValue]) => [
427+ encodePathPart(key),
428+ encodePathPart(entryValue),
429+ ]).join(",")}`,
430+ };
431+ }
432+
433+ return {
434+ kind: "path",
435+ value:
436+ explode
437+ ? entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(",")
438+ : entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(","),
439+ };
440+ }
441+
442+ if (style === "label") {
443+ return {
444+ kind: "path",
445+ value: `.${encodePathPart(value)}`,
446+ };
447+ }
448+
449+ if (style === "matrix") {
450+ return {
451+ kind: "path",
452+ value: `;${encodeURIComponent(parameter.name)}=${encodePathPart(value)}`,
453+ };
454+ }
455+
456+ return {
457+ kind: "path",
458+ value: encodePathPart(value),
459+ };
460+};
461+
462+export const serializeOpenApiParameterValue = (
463+ parameter: OpenApiSerializableParameter,
464+ value: unknown,
465+): SerializedOpenApiParameterValue => {
466+ switch (parameter.location) {
467+ case "path":
468+ return serializePathParameter(parameter, value);
469+ case "query":
470+ return serializeQueryParameter(parameter, value);
471+ case "header":
472+ return serializeHeaderParameter(parameter, value);
473+ case "cookie":
474+ return serializeCookieParameter(parameter, value);
475+ }
476+};
477+
478+export const withSerializedQueryEntries = (
479+ url: URL,
480+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>,
481+): string => {
482+ if (entries.length === 0) {
483+ return url.toString();
484+ }
485+
486+ const suffix = entries
487+ .map((entry) =>
488+ `${encodeURIComponent(entry.name)}=${encodeQueryValue(entry.value, entry.allowReserved === true)}`
489+ )
490+ .join("&");
491+
492+ const urlText = url.toString();
493+ const [withoutHash, hashFragment] = urlText.split("#", 2);
494+ const separator =
495+ withoutHash.includes("?")
496+ ? (withoutHash.endsWith("?") || withoutHash.endsWith("&") ? "" : "&")
497+ : "?";
498+
499+ return `${withoutHash}${separator}${suffix}${hashFragment ? `#${hashFragment}` : ""}`;
500+};
501+
502+export const serializeOpenApiRequestBody = (input: {
503+ requestBody: OpenApiSerializableRequestBody;
504+ body: unknown;
505+}): {
506+ contentType: string;
507+ bodyText: string;
508+} => {
509+ const contentType = preferredContentType(input.requestBody) ?? "application/json";
510+
511+ return {
512+ contentType,
513+ bodyText: serializeContentValue(input.body, contentType),
514+ };
515+};

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t