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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ time rather than passing silently:
|---|---|
| `prefixItems` | The array stays a slice of one element type. A tuple has no Go shape a slice can hold. |
| `dependentSchemas` | Not enforced. A property whose shape depends on another is a validation rule, not a type. |
| `if` / `then` / `else` | Not enforced, for the same reason: a shape that depends on a value is not a type. |
| `patternProperties` with several patterns | The map takes an `any` value type, since the patterns disagree about what a key holds. One pattern types the map. |
| `links` | Read and not used. Following a link is a decision for the caller, not a generated method. |
| `mutualTLS` security scheme | No auth provider. The certificate is configured on the `http.Client`. |
Expand Down
2 changes: 2 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Analyzer struct {
prefixItemsSeen bool
dependentSchemasSeen bool
manyPatternPropertiesSeen bool
conditionalSchemasSeen bool
// warnings collects what the generator had to skip, for the CLI to report.
warnings []string
// linksSeen records that the spec describes response links, which the
Expand Down Expand Up @@ -123,6 +124,7 @@ func (a *Analyzer) Analyze(packageName string) (*ir.Package, error) {
{a.prefixItemsSeen, "prefixItems describes a tuple, which has no Go shape a struct can hold, so those arrays stay slices of one element type"},
{a.dependentSchemasSeen, "dependentSchemas makes a property's shape conditional, which a Go struct cannot express, so it is not enforced"},
{a.manyPatternPropertiesSeen, "patternProperties with more than one pattern disagrees about what a key holds, so those maps take an any value type"},
{a.conditionalSchemasSeen, "if/then/else makes a shape depend on a value, which a Go struct cannot express, so it is not enforced"},
} {
if note.seen {
pkg.Warnings = append(pkg.Warnings, note.text)
Expand Down
93 changes: 93 additions & 0 deletions internal/analyzer/schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,13 +595,35 @@ func (a *Analyzer) convertPrimitive(goName, primaryType string, schema *highbase
// noteUnsupportedKeywords records the JSON Schema keywords the generator reads
// and cannot express in a Go type, so the spec's author hears about it once
// rather than discovering it in the output.
// constGoType returns the Go type a const value implies, for a schema that
// states none of its own.
func constGoType(schema *highbase.Schema) (string, bool) {
if schema.Const == nil {
return "", false
}
switch schema.Const.Tag {
case "!!str":
return "string", true
case "!!int":
return "int64", true
case "!!float":
return "float64", true
case "!!bool":
return "bool", true
}
return "", false
}

func (a *Analyzer) noteUnsupportedKeywords(schema *highbase.Schema) {
if len(schema.PrefixItems) > 0 {
a.prefixItemsSeen = true
}
if schema.DependentSchemas != nil && schema.DependentSchemas.Len() > 0 {
a.dependentSchemasSeen = true
}
if schema.If != nil || schema.Then != nil || schema.Else != nil {
a.conditionalSchemasSeen = true
}
if schema.PatternProperties != nil && schema.PatternProperties.Len() > 1 {
a.manyPatternPropertiesSeen = true
}
Expand Down Expand Up @@ -629,8 +651,29 @@ func (a *Analyzer) resolveGoType(schema *highbase.Schema, nameHint string) strin
return name
}

// An allOf composes other schemas. A lone $ref inside one is how a 3.0 spec
// hangs a description or nullable on a reference, since keywords beside a
// $ref are ignored, and it means the type it references. Anything else
// composes a shape of its own and is named like any other inline schema.
if len(schema.AllOf) > 0 {
if len(schema.AllOf) == 1 {
if goType := a.goTypeForRef(schema.AllOf[0].GetReference()); goType != "" {
return goType
}
}
if name, ok := a.synthesizeInlineAllOf(schema, nameHint); ok {
return name
}
}

// Enum type referenced inline -- use the primary type.
primaryType := primaryType(schema)
if primaryType == "" {
// A const says what the value is, which says what type it has.
if goType, ok := constGoType(schema); ok {
return goType
}
}

switch primaryType {
case "object":
Expand Down Expand Up @@ -769,6 +812,56 @@ func (a *Analyzer) synthesizeInlineObject(schema *highbase.Schema, nameHint stri
return goName, true
}

// synthesizeInlineAllOf declares a named struct for a composition written inline,
// so what it composes stays typed instead of collapsing into any.
func (a *Analyzer) synthesizeInlineAllOf(schema *highbase.Schema, nameHint string) (string, bool) {
// Multipart is a property of where the schema is used, and a body composed
// through allOf is used the same way one written as an object is: its binary
// properties are file parts, not base64 text.
multipartBody := a.inlineMultipartBodies[nameHint]

if schema.Title != "" {
nameHint = schema.Title
}
if nameHint == "" {
return "", false
}

key := a.inlineAllOfKey(schema, nameHint)
if existing, ok := a.synthesizedByKey[key]; ok {
return existing.Name, true
}

goName := a.namer.Unique(naming.Exported(nameHint))
td, err := a.convertAllOf(goName, schema, false, multipartBody)
if err != nil || td == nil {
return "", false
}
a.synthesizedByKey[key] = td
a.synthesized = append(a.synthesized, td)
return goName, true
}

// inlineAllOfKey identifies a composition by what it composes, so the same one
// written twice lands on one type.
func (a *Analyzer) inlineAllOfKey(schema *highbase.Schema, nameHint string) string {
var b strings.Builder
b.WriteString("allOf")
for i, proxy := range schema.AllOf {
if ref := proxy.GetReference(); ref != "" {
b.WriteString("|" + ref)
continue
}
entry, err := proxy.BuildSchema()
if err != nil || entry == nil {
b.WriteString("|?")
continue
}
b.WriteString("|" + a.inlineObjectKey(entry, nameHint+"Part"+strconv.Itoa(i)))
}
return b.String()
}

// inlineObjectKey identifies an inline object by the Go it would generate, so two
// properties declaring the same shape land on one type rather than two names for
// it.
Expand Down
193 changes: 193 additions & 0 deletions internal/analyzer/schemas_allof_property_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package analyzer

import "testing"

const allOfPropertySpec = `openapi: 3.1.0
info: { title: t, version: "1" }
paths: {}
components:
schemas:
View:
type: object
properties:
creator:
allOf:
- $ref: "#/components/schemas/User"
author:
readOnly: true
description: The author.
allOf:
- $ref: "#/components/schemas/User"
same:
allOf:
- $ref: "#/components/schemas/User"
composed:
description: A user with a role beside it.
allOf:
- $ref: "#/components/schemas/User"
- type: object
properties:
role: { type: string }
inline:
allOf:
- type: object
properties:
only: { type: string }
User:
type: object
properties:
login: { type: string }
required: [login]
`

// allOf is JSON Schema composition, and a lone $ref inside one says the value
// must match that schema, which is to say it is of that type.
func TestAllOfProperty_LoneRefResolvesToIt(t *testing.T) {
_, typeMap := analyzeSpec(t, allOfPropertySpec)

view := typeMap["View"]
if view == nil {
t.Fatal("View not found")
}
byJSON := map[string]string{}
for _, f := range view.Fields {
byJSON[f.JSONName] = f.Type
}

if byJSON["creator"] != "*User" {
t.Errorf("creator = %q, want *User", byJSON["creator"])
}
// Keywords beside the allOf are handled where they belong, so the
// composition still resolves to what it composes.
if byJSON["author"] != "*User" {
t.Errorf("author = %q, want *User", byJSON["author"])
}
if byJSON["same"] != "*User" {
t.Errorf("same = %q, want *User", byJSON["same"])
}
}

// A composition of more than a reference is a shape of its own, and gets a name
// like any other inline schema.
func TestAllOfProperty_CompositionIsNamed(t *testing.T) {
_, typeMap := analyzeSpec(t, allOfPropertySpec)

view := typeMap["View"]
byJSON := map[string]string{}
for _, f := range view.Fields {
byJSON[f.JSONName] = f.Type
}

if byJSON["composed"] != "*ViewComposed" {
t.Fatalf("composed = %q, want *ViewComposed", byJSON["composed"])
}
composed := typeMap["ViewComposed"]
if composed == nil {
t.Fatal("ViewComposed not synthesized")
}
if len(composed.Fields) != 2 {
t.Fatalf("ViewComposed fields = %+v, want the embed and the property", composed.Fields)
}
if !composed.Fields[0].Embedded || composed.Fields[0].Type != "User" {
t.Errorf("ViewComposed field 0 = %+v, want an embedded User", composed.Fields[0])
}
if composed.Fields[1].JSONName != "role" {
t.Errorf("ViewComposed field 1 = %+v, want role", composed.Fields[1])
}

// A single inline entry composes just as much as several do.
if byJSON["inline"] != "*ViewInline" {
t.Errorf("inline = %q, want *ViewInline", byJSON["inline"])
}
}

const refIdiomSpec = `openapi: 3.1.0
info: { title: t, version: "1" }
paths: {}
components:
schemas:
Advisory:
type: object
properties:
siblings:
$ref: "#/components/schemas/User"
description: 3.1 allows keywords beside a reference.
readOnly: true
nullableRef:
anyOf:
- $ref: "#/components/schemas/User"
- type: "null"
User:
type: object
properties: { login: { type: string } }
required: [login]
`

// The ways 3.1 says the same things without allOf: keywords beside a $ref, and a
// null member for a nullable one. Both resolve to the referenced type, and this
// keeps them that way.
func TestRefIdioms_3_1(t *testing.T) {
_, typeMap := analyzeSpec(t, refIdiomSpec)

byJSON := map[string]string{}
for _, f := range typeMap["Advisory"].Fields {
byJSON[f.JSONName] = f.Type
}
if byJSON["siblings"] != "*User" {
t.Errorf("a $ref with sibling keywords = %q, want *User", byJSON["siblings"])
}
if byJSON["nullableRef"] != "*User" {
t.Errorf("anyOf with a null member = %q, want *User", byJSON["nullableRef"])
}
}

const allOfMultipartSpec = `openapi: 3.1.0
info: { title: t, version: "1" }
paths:
/upload:
post:
operationId: upload
requestBody:
required: true
content:
multipart/form-data:
schema:
allOf:
- $ref: "#/components/schemas/Meta"
- type: object
properties:
file: { type: string, format: binary }
required: [file]
responses:
"204": { description: ok }
components:
schemas:
Meta:
type: object
properties:
label: { type: string }
required: [label]
`

// Multipart is a property of where a schema is used, and a body composed through
// allOf is used the same way one written as an object is: its binary properties
// are file parts rather than base64 text in an ordinary field.
func TestAllOfProperty_MultipartBodyKeepsItsFileParts(t *testing.T) {
_, typeMap := analyzeSpec(t, allOfMultipartSpec)

body := typeMap["UploadBody"]
if body == nil {
t.Fatal("UploadBody not found")
}
byJSON := map[string]string{}
for _, f := range body.Fields {
byJSON[f.JSONName] = f.Type
}
if byJSON["file"] != "FormFile" {
t.Errorf("file = %q, want FormFile", byJSON["file"])
}
// What it composes comes along.
if len(body.Fields) != 2 || !body.Fields[0].Embedded || body.Fields[0].Type != "Meta" {
t.Errorf("fields = %+v, want the embedded Meta beside the file", body.Fields)
}
}
Loading