Showing the changes to packages/codemode-openapi/src/openapi-http-serialization.ts (1 of 30 files). Show all
Unified · Split
| @@ -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 | }; | ||