diff --git a/demo/api.ts b/demo/api.ts index 0eb7c9ee..c171e8b8 100644 --- a/demo/api.ts +++ b/demo/api.ts @@ -542,10 +542,10 @@ export function getObjectParameters( commaArray, commaObject, }), - QS.space({ + QS.formSpace({ spaceDelimited, }), - QS.pipe({ + QS.formPipe({ pipeDelimited, }), QS.deep({ @@ -570,3 +570,11 @@ export function uploadPng(body?: Blob, opts?: Oazapfts.RequestOpts) { body, }); } +export function getIssue319ByUserIds( + userIds: string[], + opts?: Oazapfts.RequestOpts +) { + return oazapfts.fetchText(`/issue319/${QS.simple(userIds)}`, { + ...opts, + }); +} diff --git a/demo/enumApi.ts b/demo/enumApi.ts index 3732e72a..ea0c34a5 100644 --- a/demo/enumApi.ts +++ b/demo/enumApi.ts @@ -542,10 +542,10 @@ export function getObjectParameters( commaArray, commaObject, }), - QS.space({ + QS.formSpace({ spaceDelimited, }), - QS.pipe({ + QS.formPipe({ pipeDelimited, }), QS.deep({ @@ -570,6 +570,14 @@ export function uploadPng(body?: Blob, opts?: Oazapfts.RequestOpts) { body, }); } +export function getIssue319ByUserIds( + userIds: string[], + opts?: Oazapfts.RequestOpts +) { + return oazapfts.fetchText(`/issue319/${QS.simple(userIds)}`, { + ...opts, + }); +} export enum Status { Available = "Available", Pending = "Pending", diff --git a/demo/optimisticApi.ts b/demo/optimisticApi.ts index 6deb8e3e..6a5e2b27 100644 --- a/demo/optimisticApi.ts +++ b/demo/optimisticApi.ts @@ -587,10 +587,10 @@ export function getObjectParameters( commaArray, commaObject, }), - QS.space({ + QS.formSpace({ spaceDelimited, }), - QS.pipe({ + QS.formPipe({ pipeDelimited, }), QS.deep({ @@ -618,3 +618,13 @@ export function uploadPng(body?: Blob, opts?: Oazapfts.RequestOpts) { }) ); } +export function getIssue319ByUserIds( + userIds: string[], + opts?: Oazapfts.RequestOpts +) { + return oazapfts.ok( + oazapfts.fetchText(`/issue319/${QS.simple(userIds)}`, { + ...opts, + }) + ); +} diff --git a/demo/petstore.json b/demo/petstore.json index d07dd27c..3484a6b7 100644 --- a/demo/petstore.json +++ b/demo/petstore.json @@ -1033,6 +1033,29 @@ } ] } + }, + "/issue319/{userIds}": { + "get": { + "parameters": [ + { + "name": "userIds", + "in": "path", + "explode": false, + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "ok" + } + } + } } }, "components": { diff --git a/src/codegen/generate.test.ts b/src/codegen/generate.test.ts index 2f35a6c3..4b75f1a6 100644 --- a/src/codegen/generate.test.ts +++ b/src/codegen/generate.test.ts @@ -1,4 +1,4 @@ -import ApiGenerator, { getOperationName } from "./generate"; +import ApiGenerator, { getOperationName, getFormatter } from "./generate"; import { printAst } from "./index"; import SwaggerParser from "@apidevtools/swagger-parser"; import { OpenAPIV3 } from "openapi-types"; @@ -139,3 +139,10 @@ describe("generate with const", () => { expect(oneLine).toContain(constTypeDefinition); }); }); + +// // https://spec.openapis.org/oas/v3.0.3#style-examples +// describe('getFormatter', () => { +// it('formats correctly', () => { +// const formatter = getFormatter() +// }) +// }) diff --git a/src/codegen/generate.ts b/src/codegen/generate.ts index cac5c6ac..1a577fbc 100644 --- a/src/codegen/generate.ts +++ b/src/codegen/generate.ts @@ -44,6 +44,7 @@ export function getFormatter({ }: OpenAPIV3.ParameterObject) { if (explode && style === "deepObject") return "deep"; if (explode) return "explode"; + if (style === "simple") return "simple"; if (style === "spaceDelimited") return "space"; if (style === "pipeDelimited") return "pipe"; return "form"; @@ -101,23 +102,32 @@ export function getReferenceName(obj: any) { } } +const formatPrimitivePathParameter = (name: string) => { + const expression = _.camelCase(name); + return cg.createCall(factory.createIdentifier("encodeURIComponent"), { + args: [factory.createIdentifier(expression)], + }); +}; + /** * Create a template string literal from the given OpenAPI urlTemplate. * Curly braces in the path are turned into identifier expressions, * which are read from the local scope during runtime. */ -export function createUrlExpression(path: string, qs?: ts.Expression) { +export function createUrlExpression( + path: string, + qs?: ts.Expression, + formatPathParameter: ( + name: string + ) => ts.Expression = formatPrimitivePathParameter +) { const spans: Array<{ expression: ts.Expression; literal: string }> = []; // Use a replacer function to collect spans as a side effect: const head = path.replace( /(.*?)\{(.+?)\}(.*?)(?=\{|$)/g, (_substr, head, name, literal) => { - const expression = _.camelCase(name); spans.push({ - expression: cg.createCall( - factory.createIdentifier("encodeURIComponent"), - { args: [factory.createIdentifier(expression)] } - ), + expression: formatPathParameter(name), literal, }); return head; @@ -683,6 +693,34 @@ export default class ApiGenerator { return this.opts?.optimistic ? callOazapftsFunction("ok", [ex]) : ex; } + formatPathParameter(name: string, parameter?: OpenAPIV3.ParameterObject) { + const schema = parameter?.schema + ? this.resolve(parameter.schema) + : null; + + if (parameter && (schema?.type === "array" || schema?.type === "object")) { + const expression = _.camelCase(name); + const formatter = getFormatter({ + style: "simple", + explode: parameter.style === "form", + ...parameter, + }); + + return callQsFunction( + formatter, + formatter === "simple" || formatter === "pipe" || formatter === "space" + ? [factory.createIdentifier(expression)] + : [ + cg.createObjectLiteral([ + [expression, factory.createIdentifier(expression)], + ]), + ] + ); + } + + return formatPrimitivePathParameter(name); + } + generateApi() { this.reset(); @@ -853,16 +891,32 @@ export default class ApiGenerator { "query", Object.entries(paramsByFormatter).map(([format, params]) => { //const [allowReserved, encodeReserved] = _.partition(params, "allowReserved"); - return callQsFunction(format, [ - cg.createObjectLiteral( - params.map((p) => [p.name, argNames[p.name]]) - ), - ]); + return callQsFunction( + format === "simple" + ? "form" + : format === "pipe" + ? "formPipe" + : format === "space" + ? "formSpace" + : format, + [ + cg.createObjectLiteral( + params.map((p) => [p.name, argNames[p.name]]) + ), + ] + ); }) ); } - const url = createUrlExpression(path, qs); + const url = createUrlExpression(path, qs, (name) => + this.formatPathParameter( + name, + parameters.find( + ({ name: n, in: loc }) => loc === "path" && n === name + ) + ) + ); const init: ts.ObjectLiteralElementLike[] = [ factory.createSpreadAssignment(factory.createIdentifier("opts")), ]; diff --git a/src/runtime/query.test.ts b/src/runtime/query.test.ts index d9516070..2083c6d9 100644 --- a/src/runtime/query.test.ts +++ b/src/runtime/query.test.ts @@ -4,11 +4,17 @@ describe("delimited", () => { it("should use commas", () => { expect(qs.form({ id: [3, 4, 5] })).toEqual("id=3,4,5"); }); + it("should use pipes for form", () => { + expect(qs.formPipe({ id: [3, 4, 5] })).toEqual("id=3|4|5"); + }); + it("should use spaces for form", () => { + expect(qs.formSpace({ id: [3, 4, 5] })).toEqual("id=3%204%205"); + }); it("should use pipes", () => { - expect(qs.pipe({ id: [3, 4, 5] })).toEqual("id=3|4|5"); + expect(qs.pipe([3, 4, 5])).toEqual("3|4|5"); }); it("should use spaces", () => { - expect(qs.space({ id: [3, 4, 5] })).toEqual("id=3%204%205"); + expect(qs.space([3, 4, 5])).toEqual("3%204%205"); }); it("should enumerate entries", () => { expect(qs.form({ author: { firstName: "Felix", role: "admin" } })).toEqual( diff --git a/src/runtime/query.ts b/src/runtime/query.ts index f3fd85c4..d5264d77 100644 --- a/src/runtime/query.ts +++ b/src/runtime/query.ts @@ -1,4 +1,9 @@ -import { encode, delimited, encodeReserved } from "./util"; +import { + encode, + form as encodeForm, + simple as encodeSimple, + encodeReserved, +} from "./util"; /** * Join params using an ampersand and prepends a questionmark if not empty. @@ -61,6 +66,9 @@ export function explode( .join("&"); } -export const form = delimited(); -export const pipe = delimited("|"); -export const space = delimited("%20"); +export const form = encodeForm(); +export const formPipe = encodeForm("|"); +export const formSpace = encodeForm("%20"); +export const simple = encodeSimple(); +export const pipe = encodeSimple("|"); +export const space = encodeSimple("%20"); diff --git a/src/runtime/util.ts b/src/runtime/util.ts index a5120d59..6c39b108 100644 --- a/src/runtime/util.ts +++ b/src/runtime/util.ts @@ -4,6 +4,21 @@ type Encoders = Array<(s: string) => string>; export const encodeReserved = [encodeURIComponent, encodeURIComponent]; export const allowReserved = [encodeURIComponent, encodeURI]; +function join( + value: object, + encoder: Encoders[0] = encodeURIComponent, + delimiter = "," +) { + if (Array.isArray(value)) { + return value.map(encoder).join(delimiter); + } + const flat = Object.entries(value).reduce( + (flat, entry) => [...flat, ...entry], + [] as any + ); + return flat.map(encoder).join(delimiter); +} + /** * Creates a tag-function to encode template strings with the given encoders. */ @@ -14,14 +29,7 @@ export function encode(encoders: Encoders, delimiter = ",") { return ""; } if (typeof v === "object") { - if (Array.isArray(v)) { - return v.map(encoder).join(delimiter); - } - const flat = Object.entries(v).reduce( - (flat, entry) => [...flat, ...entry], - [] as any - ); - return flat.map(encoder).join(delimiter); + return join(v, encoder, delimiter); } return encoder(String(v)); @@ -34,10 +42,19 @@ export function encode(encoders: Encoders, delimiter = ",") { }; } +export function simple(delimiter = ",") { + return ( + param: unknown[] | Record, + encoders = encodeReserved + ) => { + return join(param, encoders[1], delimiter); + }; +} + /** * Separate array values by the given delimiter. */ -export function delimited(delimiter = ",") { +export function form(delimiter = ",") { return (params: Record, encoders = encodeReserved) => Object.entries(params) .filter(([, value]) => value !== undefined)