diff --git a/internal/gen/typescript_test.go b/internal/gen/typescript_test.go new file mode 100644 index 0000000..ab2a05c --- /dev/null +++ b/internal/gen/typescript_test.go @@ -0,0 +1,83 @@ +package gen + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestTypeScriptFetchContainerSerialization guards apis.mustache: a map of arrays response +// maps each element, and a multipart array of models is one JSON part, while arrays of +// files, primitives, enums and primitive aliases keep their per-element and csv encodings. +func TestTypeScriptFetchContainerSerialization(t *testing.T) { + spec := writeSpec(t, "spec.yaml", `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /pets: + get: + operationId: getPets + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, additionalProperties: {type: array, items: {$ref: "#/components/schemas/Pet"}}} + post: + operationId: addPets + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + colors: {type: array, items: {$ref: "#/components/schemas/Color"}} + ids: {type: array, items: {$ref: "#/components/schemas/Id"}} + tags: {type: array, items: {type: string}} + files: {type: array, items: {type: string, format: binary}} + responses: + "204": {description: ok} +components: + schemas: + Color: {type: string, enum: [red, blue]} + Id: {type: integer} + Pet: {type: object, properties: {name: {type: string}}} +`) + + out := t.TempDir() + + err := Generate(Options{ + InputSpec: spec, + OutputDir: out, + GeneratorName: "typescript-fetch", + TemplateDir: filepath.Join("..", "..", "templates", "typescript-fetch"), + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + data, err := os.ReadFile(filepath.Join(out, "apis", "defaultApi.ts")) + if err != nil { + t.Fatal(err) + } + + api := string(data) + + for _, want := range []string{ + `runtime.mapValues(jsonValue, (items) => items.map(PetFromJSON))`, + `formParams.append("pets", new Blob([JSON.stringify(requestParameters["pets"].map(PetToJSON))], { type: "application/json", }))`, + `formParams.append("colors", requestParameters["colors"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, + `formParams.append("ids", requestParameters["ids"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, + `formParams.append("tags", requestParameters["tags"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, + `formParams.append("files", element as any)`, + } { + if !strings.Contains(api, want) { + t.Errorf("defaultApi.ts lacks %s", want) + } + } + + if strings.Contains(api, "ArrayPet") { + t.Error("defaultApi.ts imports ArrayPet") + } +} diff --git a/internal/parser/imports.go b/internal/parser/imports.go index 6076014..7e7a37e 100644 --- a/internal/parser/imports.go +++ b/internal/parser/imports.go @@ -92,13 +92,19 @@ func (p *Parser) collectOperationImports(op *codegen.CodegenOperation) []string } } + // A map of arrays has the base type "Array"; the model to import is X. + returnBaseType := op.ReturnBaseType + if rp := op.ReturnProperty; rp != nil && rp.IsMap && rp.Items != nil && rp.Items.IsArray { + returnBaseType = rp.Items.BaseType + } + // From return type - also check that ReturnBaseType is not primitive. A // composite (union/intersection) return type imports its member models // instead of the joined string, which is not an importable identifier; // such returns carry ReturnTypeIsPrimitive so no FromJSON import is needed. if op.ReturnType != "" && !op.ReturnTypeIsPrimitive && - !isPrimitiveType(op.ReturnType) && !isPrimitiveType(op.ReturnBaseType) && !isCompositeType(op.ReturnBaseType) { - imports[op.ReturnBaseType] = true + !isPrimitiveType(op.ReturnType) && !isPrimitiveType(returnBaseType) && !isCompositeType(returnBaseType) { + imports[returnBaseType] = true } for _, m := range op.ReturnComposedModels { diff --git a/internal/parser/imports_test.go b/internal/parser/imports_test.go index 2437176..2c34ee4 100644 --- a/internal/parser/imports_test.go +++ b/internal/parser/imports_test.go @@ -1,6 +1,9 @@ package parser -import "testing" +import ( + "reflect" + "testing" +) // TestBareMapModelImportsNestedContainerModel guards against a regression where a model // that is itself a bare map (type: object + additionalProperties, no named properties of @@ -67,3 +70,70 @@ components: t.Fatalf("MapOfArrays.Imports = %v, want it to contain %q", mapOfArrays.Imports, "Cell") } } + +// TestMapOfArraysResponseImportsItemModel guards the return import of a map of arrays: +// { [key: string]: Array; } imports Pet, not a non-existent ArrayPet, and a map of +// arrays of primitives or of nested arrays imports nothing and needs no mapper. +func TestMapOfArraysResponseImportsItemModel(t *testing.T) { + spec := []byte(` +openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /pets: + get: + operationId: getPets + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, additionalProperties: {type: array, items: {$ref: "#/components/schemas/Pet"}}} + /tags: + get: + operationId: getTags + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, additionalProperties: {type: array, items: {type: string}}} + /grid: + get: + operationId: getGrid + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, additionalProperties: {type: array, items: {type: array, items: {$ref: "#/components/schemas/Pet"}}}} +components: + schemas: + Pet: {type: object, properties: {name: {type: string}}} +`) + + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData(spec); err != nil { + t.Fatalf("LoadFromData: %v", err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatalf("GetOperations: %v", err) + } + + want := map[string][]string{"getPets": {"Pet"}, "getTags": {}, "getGrid": {}} + + for _, group := range ops { + for _, op := range group { + if !reflect.DeepEqual(op.Imports, want[op.OperationId]) { + t.Errorf("%s: Imports = %v, want %v", op.OperationId, op.Imports, want[op.OperationId]) + } + + if wantPrimitive := op.OperationId != "getPets"; op.ReturnTypeIsPrimitive != wantPrimitive { + t.Errorf("%s: ReturnTypeIsPrimitive = %v, want %v", op.OperationId, op.ReturnTypeIsPrimitive, wantPrimitive) + } + } + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 4f29221..6327402 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -320,6 +320,14 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc fp.IsCollectionFormatMulti = true fp.CollectionFormat = "multi" } + + // An array of object models goes as one JSON part, but an array of + // $ref to a primitive or enum schema is csv-joined like any primitive. + if items := propRef.Value.Items; prop.Items != nil && items != nil && items.Value != nil { + t := items.Value.Type + prop.Items.IsPrimitiveType = prop.Items.IsPrimitiveType || + t.Includes("string") || t.Includes("integer") || t.Includes("number") || t.Includes("boolean") + } } co.FormParams = append(co.FormParams, fp) @@ -560,6 +568,7 @@ func (p *Parser) responses(co *codegen.CodegenOperation, responses *openapi3.Res func setReturnType(co *codegen.CodegenOperation, resp *codegen.CodegenResponse) { co.ReturnType = resp.DataType co.ReturnBaseType = resp.BaseType + co.ReturnProperty = resp.ReturnProperty co.ReturnComposedModels = resp.ComposedModels co.ReturnSimpleType = resp.SimpleType diff --git a/internal/parser/response.go b/internal/parser/response.go index e5201d2..5a9c2bb 100644 --- a/internal/parser/response.go +++ b/internal/parser/response.go @@ -68,6 +68,7 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege cr.DataType = prop.DataType cr.BaseType = prop.BaseType + cr.ReturnProperty = prop cr.IsArray = prop.IsArray cr.IsMap = prop.IsMap cr.IsModel = prop.IsModel @@ -85,9 +86,15 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege // FromJSON mapper. For array/map responses the relevant question is whether the // element type is primitive: a primitive element (e.g. Array) needs no // mapper and must not reference an undefined numberFromJSON helper. SimpleType - // is reserved for scalar primitives only. + // is reserved for scalar primitives only. A map of arrays + // ({ [key: string]: Array; }) maps each value element by element, so + // what counts there is whether X is a model. basePrimitive := prop.IsPrimitiveType - if (prop.IsArray || prop.IsMap) && prop.Items != nil { + + switch { + case prop.IsMap && prop.Items != nil && prop.Items.IsArray: + basePrimitive = prop.Items.Items == nil || !prop.Items.Items.IsModel + case (prop.IsArray || prop.IsMap) && prop.Items != nil: basePrimitive = prop.Items.IsPrimitiveType } diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index 305644b..3495e28 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -225,7 +225,17 @@ export class {{classname}} extends runtime.BaseAPI { }) {{/isCollectionFormatMulti}} {{^isCollectionFormatMulti}} +{{#items.isModel}} +{{^items.isPrimitiveType}} + formParams.append("{{baseName}}", new Blob([JSON.stringify({{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}{{^withoutRuntimeChecks}}.map({{items.dataType}}ToJSON){{/withoutRuntimeChecks}})], { type: "application/json", })); +{{/items.isPrimitiveType}} +{{#items.isPrimitiveType}} formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); +{{/items.isPrimitiveType}} +{{/items.isModel}} +{{^items.isModel}} + formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); +{{/items.isModel}} {{/isCollectionFormatMulti}} } {{/isArray}} @@ -363,7 +373,7 @@ export class {{classname}} extends runtime.BaseAPI { {{/isArray}} {{^isArray}} {{#isMap}} - return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}); + return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{#returnProperty.items.isArray}}(items) => items.map({{returnProperty.items.baseType}}FromJSON){{/returnProperty.items.isArray}}{{^returnProperty.items.isArray}}{{returnBaseType}}FromJSON{{/returnProperty.items.isArray}}){{/withoutRuntimeChecks}}); {{/isMap}} {{^isMap}} return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{returnBaseType}}FromJSON(jsonValue){{/withoutRuntimeChecks}});