diff --git a/README.md b/README.md index 0a9378d..74a965e 100644 --- a/README.md +++ b/README.md @@ -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`. | diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index e5f99c6..371118d 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -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 @@ -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) diff --git a/internal/analyzer/schemas.go b/internal/analyzer/schemas.go index 63fea63..4b8d43e 100644 --- a/internal/analyzer/schemas.go +++ b/internal/analyzer/schemas.go @@ -595,6 +595,25 @@ 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 @@ -602,6 +621,9 @@ func (a *Analyzer) noteUnsupportedKeywords(schema *highbase.Schema) { 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 } @@ -631,6 +653,12 @@ func (a *Analyzer) resolveGoType(schema *highbase.Schema, nameHint string) strin // 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": diff --git a/internal/analyzer/schemas_const_test.go b/internal/analyzer/schemas_const_test.go new file mode 100644 index 0000000..c1510c9 --- /dev/null +++ b/internal/analyzer/schemas_const_test.go @@ -0,0 +1,75 @@ +package analyzer + +import ( + "strings" + "testing" +) + +const constSpec = `openapi: 3.1.0 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Thing: + type: object + properties: + kind: { const: dog } + count: { const: 3 } + ratio: { const: 1.5 } + enabled: { const: true } + stated: { type: string, const: v2 } + shaped: + const: { a: 1 } + conditional: + type: object + properties: { a: { type: string } } + if: { properties: { a: { const: x } } } + then: { required: [b] } +` + +// A const says what the value is, which says what type it has. 3.1 specs pin a +// discriminator this way, and the property was resolving to any. +func TestConst_ImpliesTheType(t *testing.T) { + _, typeMap := analyzeSpec(t, constSpec) + + byJSON := map[string]string{} + for _, f := range typeMap["Thing"].Fields { + byJSON[f.JSONName] = f.Type + } + + // Optional properties are pointers, as everywhere else. + for prop, want := range map[string]string{ + "kind": "*string", + "count": "*int64", + "ratio": "*float64", + "enabled": "*bool", + // A schema that states its own type keeps it. + "stated": "*string", + } { + if byJSON[prop] != want { + t.Errorf("%s = %q, want %q", prop, byJSON[prop], want) + } + } + + // A const that is not a scalar says nothing a Go type can carry on its own. + if byJSON["shaped"] != "any" { + t.Errorf("shaped = %q, want any", byJSON["shaped"]) + } +} + +// if/then/else makes a shape depend on a value, which is a validation rule +// rather than a type, and was dropped without a word while its sibling keyword +// dependentSchemas warned. +func TestConditionalSchemas_Warn(t *testing.T) { + pkg, _ := analyzeSpec(t, constSpec) + + var found int + for _, w := range pkg.Warnings { + if strings.Contains(w, "if/then/else") { + found++ + } + } + if found != 1 { + t.Errorf("if/then/else warnings = %d, want 1: %v", found, pkg.Warnings) + } +}