Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions internal/gen/typescript_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 8 additions & 2 deletions internal/parser/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,19 @@ func (p *Parser) collectOperationImports(op *codegen.CodegenOperation) []string
}
}

// A map of arrays has the base type "Array<X>"; 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 {
Expand Down
72 changes: 71 additions & 1 deletion internal/parser/imports_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Pet>; } 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)
}
}
}
}
9 changes: 9 additions & 0 deletions internal/parser/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions internal/parser/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<number>) 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<X>; }) 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
}

Expand Down
12 changes: 11 additions & 1 deletion templates/typescript-fetch/apis.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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}});
Expand Down
Loading