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..3bb8396 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 } @@ -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": @@ -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. diff --git a/internal/analyzer/schemas_allof_property_test.go b/internal/analyzer/schemas_allof_property_test.go new file mode 100644 index 0000000..4aee7b9 --- /dev/null +++ b/internal/analyzer/schemas_allof_property_test.go @@ -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) + } +} 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) + } +} diff --git a/internal/generator/e2e_allof_property_test.go b/internal/generator/e2e_allof_property_test.go new file mode 100644 index 0000000..0fa6a6d --- /dev/null +++ b/internal/generator/e2e_allof_property_test.go @@ -0,0 +1,98 @@ +package generator + +import "testing" + +const allOfPropertyAPISpec = `openapi: 3.1.0 +info: { title: advisories, version: "1" } +paths: + /advisories/{id}: + get: + operationId: getAdvisory + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + "200": + description: ok + content: + application/json: { schema: { $ref: "#/components/schemas/Advisory" } } +components: + schemas: + Advisory: + type: object + properties: + id: { type: string } + author: + description: The author of the advisory. + allOf: + - $ref: "#/components/schemas/User" + - type: "null" + reviewer: + description: Who reviewed it, with their verdict. + allOf: + - $ref: "#/components/schemas/User" + - type: object + properties: + verdict: { type: string } + required: [id] + User: + type: object + properties: + login: { type: string } + required: [login] +` + +// TestE2E_AllOfPropertiesAreTyped covers allOf where a property is typed. The +// analyzer composed it for a named schema and not for an inline one, so a +// property resolved to any and a caller had to assert their way into a schema +// the spec names. +func TestE2E_AllOfPropertiesAreTyped(t *testing.T) { + files, _ := generateFromSpec(t, allOfPropertyAPISpec, "advapi") + + runGeneratedWireTest(t, files, "allofproperty", `package advapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWrappedRefDecodesAsItsType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`+"`"+`{"id":"a-1","author":{"login":"ada"},"reviewer":{"login":"bo","verdict":"ok"}}`+"`"+`)) + })) + defer srv.Close() + + adv, err := NewClient(srv.URL).GetAdvisory(t.Context(), "a-1") + if err != nil { + t.Fatalf("GetAdvisory: %v", err) + } + + // A plain read rather than an assertion into map[string]any. + if adv.Author == nil || adv.Author.Login != "ada" { + t.Errorf("author = %+v, want the referenced User", adv.Author) + } + + // A composition keeps both halves: what it references and what it adds. + if adv.Reviewer == nil || adv.Reviewer.Login != "bo" { + t.Errorf("reviewer = %+v, want the embedded User", adv.Reviewer) + } + if adv.Reviewer.Verdict == nil || *adv.Reviewer.Verdict != "ok" { + t.Errorf("verdict = %v, want ok", adv.Reviewer.Verdict) + } +} + +// The pointer carries absence, so a null author stays distinguishable from one +// that is present and empty. +func TestNullWrappedRefIsNil(t *testing.T) { + var adv Advisory + if err := json.Unmarshal([]byte(`+"`"+`{"id":"a-2","author":null}`+"`"+`), &adv); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if adv.Author != nil { + t.Errorf("author = %+v, want nil", adv.Author) + } +} +`) +} diff --git a/internal/generator/e2e_combinations_test.go b/internal/generator/e2e_combinations_test.go new file mode 100644 index 0000000..2077198 --- /dev/null +++ b/internal/generator/e2e_combinations_test.go @@ -0,0 +1,124 @@ +package generator + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/parallelworks/openapi-client-generator/internal/analyzer" + "github.com/parallelworks/openapi-client-generator/internal/parser" +) + +// TestE2E_Combinations generates from a spec that crosses features rather than +// exercising them one at a time, then compiles and vets the result. +// +// The per-feature tests around it each drive one thing through a spec written +// for it. What they cannot catch is a feature that quietly stops happening in +// the presence of another, or one that disappears entirely while the package +// still builds: iterators generated for no operation, a union base that stops +// being found, a file part that turns back into base64 text. Every assertion +// here is a feature that has broken that way at least once. +func TestE2E_Combinations(t *testing.T) { + specPath := filepath.Join(projectRoot(), "testdata", "combinations.yaml") + + result, err := parser.Parse(specPath, parser.Config{}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + pkg, err := analyzer.New(result.Model).Analyze("combinations") + if err != nil { + t.Fatalf("Analyze: %v", err) + } + gen, err := New(pkg) + if err != nil { + t.Fatalf("New: %v", err) + } + files, err := gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + byName := make(map[string]string, len(files)) + for _, f := range files { + byName[f.Name] = string(f.Content) + } + + for _, want := range []struct{ file, decl, why string }{ + // One iterator per pagination style, in one package. + {"pagination.go", "func (c *Client) ListThingsIter", "cursor pagination"}, + {"pagination.go", "func (c *Client) ListAlertsIter", "offset pagination over a bare array"}, + {"pagination.go", "func (c *Client) ListReportsIter", "page pagination named perPage"}, + {"pagination.go", "*PageIterator[Thing]", "an iterator over a union"}, + + // A discriminated union whose variants compose a base and pin their tag + // with const. + {"types.go", "func (u Thing) Base() *ThingBase", "the base every variant composes"}, + {"types.go", "Kind string `json:\"kind\"`", "a const tag typed as a string"}, + + // Multipart whose body composes a schema through allOf. + {"types.go", "File FormFile", "a file part in a composed multipart body"}, + {"types.go", "Meta", "the schema the multipart body composes"}, + {"operations.go", "body UploadThingBody", "a typed multipart body"}, + + // Names the templates declare, and names that normalize together. + {"types.go", "type Client2 struct", "a schema renamed off a reserved name"}, + {"types.go", "type DefaultBaseURL2 struct", "a schema renamed off a server constant"}, + {"types.go", "UserID2 *string `json:\"user_id,omitempty\"`", "properties that normalize together"}, + {"responses.go", "XTrace2", "response headers that normalize together"}, + + // The rest of the surface, each of which has regressed once. + {"client.go", "const DefaultBaseURL", "the server URL the spec declares"}, + {"client.go", "func ServerURL(region string, basePath string) string", "a templated server"}, + {"webhooks.go", "func ParseThingCreatedWebhook", "a webhook payload"}, + {"webhooks.go", "func ParseUploadThingOnStoredCallback", "a callback payload"}, + {"responses.go", "XRateLimitRemaining *int64", "a typed response header"}, + {"operations.go", "addContentQueryParam", "a parameter serialized as its media type"}, + {"operations.go", "encodeQueryAllowingReserved", "allowReserved on a query parameter"}, + {"operations.go", "params.Either", "a union-typed parameter"}, + {"errors.go", "func (e *ProblemResponse) Error() string", "a typed error wrapper"}, + {"errors.go", "e.Detail.Detail", "a message field found by its conventional name"}, + } { + if !containsCollapsed(byName[want.file], want.decl) { + t.Errorf("%s is missing %s (%s)", want.file, want.why, want.decl) + } + } + + // The public operation opts out of the credential the document requires. + if !containsCollapsed(byName["operations.go"], `"application/json", false`) { + t.Error("operations.go: the operation declaring security: [] should not authenticate") + } + + buildAndVet(t, files, "combinations") +} + +// containsCollapsed reports whether haystack holds needle once the runs of +// whitespace in both are flattened. The generated files reach this test before +// goimports aligns them, so an assertion written the way the output looks would +// depend on alignment that has not happened yet. +func containsCollapsed(haystack, needle string) bool { + return strings.Contains(strings.Join(strings.Fields(haystack), " "), strings.Join(strings.Fields(needle), " ")) +} + +// buildAndVet writes the generated files to a module and runs build and vet over +// them. vet catches what compiles and is still wrong: a shadowed error, a +// printf verb that does not match, a lost struct tag. +func buildAndVet(t *testing.T, files []GeneratedFile, module string) { + t.Helper() + dir := t.TempDir() + goMod := []byte("module " + module + "\n\ngo 1.25.5\n") + if err := os.WriteFile(filepath.Join(dir, "go.mod"), goMod, 0o644); err != nil { + t.Fatalf("writing go.mod: %v", err) + } + if err := WriteFiles(dir, files); err != nil { + t.Fatalf("WriteFiles: %v", err) + } + for _, args := range [][]string{{"build", "./..."}, {"vet", "./..."}} { + cmd := exec.Command("go", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go %s on the generated package: %v\n%s", strings.Join(args, " "), err, out) + } + } +} diff --git a/testdata/combinations.yaml b/testdata/combinations.yaml new file mode 100644 index 0000000..d2bd8fa --- /dev/null +++ b/testdata/combinations.yaml @@ -0,0 +1,209 @@ +# A 3.1 spec that crosses features rather than exercising them one at a time. +# Every generated file has to hold together at once: iterators over unions, +# composition under multipart, colliding names beside reserved ones. +openapi: 3.1.0 +info: + title: Combinations + version: "1" +servers: + - url: https://{region}.api.example.com/{basePath} + variables: + region: { default: us-east-1, enum: [us-east-1, eu-west-1] } + basePath: { default: v2 } +security: + - bearer: [] +webhooks: + thing-created: + post: + description: Sent when a thing appears. + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/Thing" } + thing_created: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + at: { type: string, format: date-time } + required: [at] +paths: + /things: + get: + operationId: listThings + parameters: + - { name: cursor, in: query, schema: { type: string } } + - name: filter + in: query + content: + application/json: + schema: { $ref: "#/components/schemas/Filter" } + - { name: ref, in: query, allowReserved: true, schema: { type: string } } + - name: either + in: query + schema: + anyOf: [{ type: string }, { type: integer }] + responses: + "200": + description: A page of things, each of which is a union. + headers: + x-trace: { schema: { type: string } } + X_Trace: { schema: { type: string } } + X-Rate-Limit-Remaining: { schema: { type: integer } } + content: + application/json: { schema: { $ref: "#/components/schemas/ThingPage" } } + default: + description: error + content: + application/json: { schema: { $ref: "#/components/schemas/Problem" } } + post: + operationId: uploadThing + 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: stored } + callbacks: + onStored: + "{$request.body#/callbackUrl}": + post: + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/Thing" } + /alerts: + get: + operationId: listAlerts + security: [] + parameters: + - { name: skip, in: query, schema: { type: integer } } + - { name: limit, in: query, schema: { type: integer } } + responses: + "200": + description: The page is the response. + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/Alert" } } + /reports: + get: + operationId: listReports + parameters: + - { name: page, in: query, schema: { type: integer } } + - { name: perPage, in: query, schema: { type: integer } } + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + results: { type: array, items: { $ref: "#/components/schemas/Alert" } } + total: { type: integer } + /files/{path}: + get: + operationId: download + parameters: + - { name: path, in: path, required: true, schema: { type: string } } + responses: + "200": + description: bytes + content: + application/octet-stream: + schema: { type: string, format: binary } +components: + securitySchemes: + bearer: { type: http, scheme: Bearer } + oidc: + type: openIdConnect + openIdConnectUrl: https://issuer.example.com/.well-known/openid-configuration + schemas: + # A name the templates also declare, and two that normalize together. + Client: + type: object + properties: + user-id: { type: string } + user_id: { type: string } + DefaultBaseURL: + type: object + properties: { v: { type: string } } + Meta: + type: object + properties: + label: { type: string } + required: [label] + Filter: + type: object + properties: + field: { type: string } + values: { type: array, items: { type: string } } + required: [field] + ThingPage: + type: object + properties: + items: { type: array, items: { $ref: "#/components/schemas/Thing" } } + nextCursor: { type: string } + # A discriminated union whose variants pin their tag the 3.1 way and share + # a base by composing it. + Thing: + oneOf: + - $ref: "#/components/schemas/Widget" + - $ref: "#/components/schemas/Gadget" + discriminator: + propertyName: kind + mapping: + widget: "#/components/schemas/Widget" + gadget: "#/components/schemas/Gadget" + ThingBase: + type: object + properties: + id: { type: string } + name: { type: string } + required: [id, name] + Widget: + allOf: + - $ref: "#/components/schemas/ThingBase" + - type: object + properties: + kind: { const: widget } + spins: { type: boolean } + required: [kind, spins] + Gadget: + allOf: + - $ref: "#/components/schemas/ThingBase" + - type: object + properties: + kind: { const: gadget } + gears: { type: integer } + required: [kind, gears] + Alert: + type: object + properties: + id: { type: string } + parent: + anyOf: + - $ref: "#/components/schemas/Alert" + - type: "null" + extensions: + type: object + patternProperties: + "^x-": { type: string } + required: [id] + Problem: + type: object + properties: + type: { type: string } + title: { type: string } + detail: { type: string } + additionalProperties: true