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 @@
1type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2
3export type OpenApiSerializableContent = {
4 mediaType: string;
5};
6
7export type OpenApiSerializableParameter = {
8 name: string;
9 location: OpenApiSerializableLocation;
10 style?: string;
11 explode?: boolean;
12 allowReserved?: boolean;
13 content?: ReadonlyArray<OpenApiSerializableContent>;
14};
15
16export type OpenApiSerializableRequestBody = {
17 contentTypes?: ReadonlyArray<string>;
18 contents?: ReadonlyArray<OpenApiSerializableContent>;
19};
20
21export type SerializedOpenApiQueryEntry = {
22 name: string;
23 value: string;
24 allowReserved?: boolean;
25};
26
27export 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
44const isRecord = (value: unknown): value is Record<string, unknown> =>
45 value !== null && typeof value === "object" && !Array.isArray(value);
46
47const 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
71const objectEntries = (value: unknown): Array<[string, unknown]> =>
72 Object.entries(isRecord(value) ? value : {})
73 .sort(([left], [right]) => left.localeCompare(right));
74
75const 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
86const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87 location === "query" || location === "cookie"
88 ? style === "form"
89 : false;
90
91const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92 parameter.style ?? defaultStyle(parameter.location);
93
94const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95 parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96
97const encodePathPart = (value: unknown): string =>
98 encodeURIComponent(primitiveString(value));
99
100const 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
127const 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
145const 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
173const 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
274const 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
311const 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
369const 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
462export 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
478export 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
502export 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