Merge pull request #53630 from vvoland/work-api

api: Move to OpenAPI 3.2 spec
This commit is contained in:
Paweł Gronowski authored and GitHub committed 2026-10-29 13:03:07 +01:00
commit 6f97760380
21 files changed
+18281 -1874

No files matched your search

+11 -1
View File
@@ -3,7 +3,15 @@
ARG GO_VERSION=1.26.10
FROM golang:${GO_VERSION}-alpine AS base
RUN apk add --no-cache bash libfyaml make yamllint
RUN apk add --no-cache bash libfyaml make yamllint py3-pip
# OpenAPI validation, including its YAML parser.
ARG OPENAPI_SPEC_VALIDATOR_VERSION=0.9.0
ARG PYYAML_VERSION=6.0.2
RUN python3 -m venv /opt/openapi && \
/opt/openapi/bin/pip install --no-cache-dir \
"openapi-spec-validator==${OPENAPI_SPEC_VALIDATOR_VERSION}" \
"PyYAML==${PYYAML_VERSION}"
ENV PATH="/opt/openapi/bin:${PATH}"
CMD ["/bin/bash"]
# go-swagger
@@ -23,3 +31,5 @@ RUN --mount=type=cache,target=/root/.cache/go-build,id=swagger-build-$TARGETPLAT
FROM base AS dev
COPY --from=swagger /go/bin/swagger /usr/local/bin/swagger
WORKDIR /go/src/github.com/moby/moby/api
COPY scripts/swagger-spec/go.mod scripts/swagger-spec/go.sum ./scripts/swagger-spec/
RUN cd scripts/swagger-spec && go mod download
+8 -4
View File
@@ -38,21 +38,25 @@ build:
.
.PHONY: swagger-gen
swagger-gen: build ## generate swagger API types
swagger-gen: build ## generate swagger.yaml and API types
$(DOCKER_RUN) ./scripts/generate-swagger-api.sh
.PHONY: swagger-spec
swagger-spec: build ## generate the Swagger 2.0 compatibility specification
$(DOCKER_RUN) ./scripts/generate-swagger-spec.sh
.PHONY: swagger-docs
swagger-docs: ## preview the API documentation
@echo "API docs preview will be running at http://localhost:$(SWAGGER_DOCS_PORT)"
@docker run --rm \
-v ./:/usr/share/nginx/html/swagger/ \
-e 'REDOC_OPTIONS=hide-hostname="true" lazy-rendering' \
-e SPEC_URL="swagger/swagger.yaml" \
-e SPEC_URL="swagger/openapi.yaml" \
-p $(SWAGGER_DOCS_PORT):80 \
redocly/redoc:v2.5.1
redocly/redoc:v2.5.3
.PHONY: validate-swagger
validate-swagger: build ## validate the swagger.yaml file
validate-swagger: build ## validate OpenAPI and the generated Swagger specification
$(DOCKER_RUN) ./scripts/validate-swagger.sh
.PHONY: validate-swagger-gen
+45 -13
View File
@@ -10,14 +10,15 @@ The Engine API is an HTTP API used by the command-line client to communicate wit
It consists of various components in this repository:
- `api/swagger.yaml` A Swagger definition of the API.
- `api/types/` Types shared by both the client and server, representing various objects, options, responses, etc. Most are written manually, but some are automatically generated from the Swagger definition. See [#27919](https://github.com/moby/moby/issues/27919) for progress on this.
- `api/openapi.yaml` An OpenAPI 3.2.0 definition of the API.
- `api/swagger.yaml` A generated Swagger 2.0 version for existing tooling.
- `api/types/` Types shared by both the client and server, representing various objects, options, responses, etc. Most are written manually, but some are automatically generated from the API definition. See [#27919](https://github.com/moby/moby/issues/27919) for progress on this.
- `client/` The Go client used by the command-line client. It can also be used by third-party Go programs.
- `daemon/` The daemon, which serves the API.
## Swagger definition
## OpenAPI definition
The API is defined by the [Swagger](http://swagger.io/specification/) definition in `api/swagger.yaml`. This definition can be used to:
The API is defined by the [OpenAPI 3.2.0](https://spec.openapis.org/oas/v3.2.0) definition in `api/openapi.yaml`. This definition can be used to:
1. Automatically generate documentation.
2. Automatically generate the Go server and client. (A work-in-progress.)
@@ -25,24 +26,55 @@ The API is defined by the [Swagger](http://swagger.io/specification/) definition
## Updating the API documentation
The API documentation is generated entirely from `api/swagger.yaml`. If you make updates to the API, edit this file to represent the change in the documentation.
Edit `api/openapi.yaml` when changing the API.
The API documentation is generated from this file.
Documentation for each API version can be found in the [docs directory](docs/README.md), which also provides a [CHANGELOG.md](docs/CHANGELOG.md).
The file is split into two main sections:
- `definitions`, which defines re-usable objects used in requests and responses
- `components.schemas`, which defines re-usable objects used in requests and responses
- `paths`, which defines the API endpoints (and some inline objects which don't need to be reusable)
To make an edit, first look for the endpoint you want to edit under `paths`, then make the required edits. Endpoints may reference reusable objects with `$ref`, which can be found in the `definitions` section.
Find the endpoint under `paths`.
Reusable schemas are referenced with `$ref: "#/components/schemas/Name"`.
There is hopefully enough example material in the file for you to copy a similar pattern from elsewhere in the file (e.g. adding new fields or endpoints), but for the full reference, see the [Swagger specification](https://github.com/moby/moby/issues/27919).
Request bodies use `requestBody.content`, and responses use `responses.<status>.content`, keyed by media type.
Non-body parameters use `schema`.
For array query parameters, specify `style: form` and `explode: true` for repeated keys or `explode: false` for comma-separated values.
An empty media object leaves the response schema unspecified; it does not mean there is no body.
`swagger.yaml` is validated by `hack/validate/swagger` to ensure it is a valid Swagger definition. This is useful when making edits to ensure you are doing the right thing.
### Swagger compatibility
OpenAPI is the source of truth.
We generate Swagger for existing tooling, including go-swagger.
For this migration, we keep the existing Swagger shape and generated Go models unchanged.
Long term, Swagger conversion should be best effort, not a limit on new API features.
The converter still rejects type unions, `anyOf`, `oneOf`, `nullable`, and `itemSchema`.
Using these features will need converter changes.
Keep `x-nullable` and other Go generator extensions: they control the generated Go code, not API validation.
Media-level request examples override schema examples and become the Swagger body schema's `example`.
The converter currently requires matching schemas and effective examples across request media types.
Response examples can differ by media type.
After editing `openapi.yaml`, run `make -C api swagger-spec` from the repository root and commit `swagger.yaml` too.
Do not edit `swagger.yaml` directly.
Regeneration keeps multiline JSON examples but may reformat other content when the specification changes.
Swagger lists all response media types in the operation's `produces` field, not per response.
Bodyless operations use `x-consumes` to keep their existing Swagger `consumes` values.
Run `make -C api validate-swagger` to check YAML style, validate both specifications, and test the converter against the checked-in Swagger.
## Generating Go models
Run `make swagger-gen` from the repository root to regenerate `api/swagger.yaml` and generate Go models with go-swagger.
Run `make -C api validate-swagger-gen` to compare regenerated models with the checked-in files.
## Viewing the API documentation
When you make edits to `swagger.yaml`, you may want to check the generated API documentation to ensure it renders correctly.
Run `make swagger-docs` to preview the documentation at `http://localhost:9000`.
Check that your changes render correctly; styling may differ from the published docs.
Run `make swagger-docs` and a preview will be running at `http://localhost:9000`. Some of the styling may be incorrect, but you'll be able to ensure that it is generating the correct documentation.
The production documentation is generated by vendoring `swagger.yaml` into [docker/docs](https://github.com/docker/docs).
Published docs use the versioned `docs/v*.yaml` specifications vendored into [docker/docs](https://github.com/docker/docs).
+3
View File
@@ -15,6 +15,9 @@ keywords: "API, Docker, rcli, REST, documentation"
## v1.57 API changes
* The API specification now uses OpenAPI 3.2.0 instead of Swagger 2.0.
A generated Swagger 2.0 version is still available for existing tooling.
## v1.56 API changes
* `GET /containers/json` now supports an `annotation` filter to filter
+8 -11
View File
@@ -10,17 +10,14 @@ Newer API versions tend to be backward-compatible with older versions,
with some exceptions where features were deprecated. For an overview
of changes for each version, refer to [CHANGELOG.md](CHANGELOG.md).
The latest version of the API specification can be found [at the root directory
of this module](../swagger.yaml) which may contain unreleased changes.
The latest specification is [openapi.yaml](../openapi.yaml), using [OpenAPI 3.2.0](https://spec.openapis.org/oas/v3.2.0).
It may include unreleased changes.
We still generate [Swagger 2.0](../swagger.yaml) for existing tooling, including Go model generation.
See the [module README](../README.md) for how to edit and validate the specification.
For API version v1.24, documentation is only available in markdown
format, for later versions [Swagger (OpenAPI) v2.0](https://swagger.io/specification/v2/)
specifications can be found in this directory. The Moby project itself
primarily uses these swagger files to produce the API documentation;
while we attempt to make these files match the actual implementation,
the OpenAPI 2.0 specification has limitations that prevent us from
expressing all options provided. There may be discrepancies (for which
we welcome contributions). If you find bugs, or discrepancies, please
open a ticket (or pull request).
For API version v1.24, documentation is only available in markdown format.
Existing `v*.yaml` files stay in [Swagger (OpenAPI) v2.0](https://swagger.io/specification/v2/) format.
These files are used to generate the published API docs.
Report differences between the specification and implementation in an issue or pull request.
+15293
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -7,6 +7,8 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API_DIR="${1:-${SCRIPT_DIR}/..}"
"${SCRIPT_DIR}/generate-swagger-spec.sh" "${API_DIR}"
generate_model() {
local package="$1"
shift
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: generate-swagger-spec.sh [api-directory]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API_DIR="$(cd "${1:-${SCRIPT_DIR}/..}" && pwd)"
# Replace the checked-in specification only after conversion succeeds.
SPEC="$(mktemp "${API_DIR}/swagger.yaml.XXXXXX")"
trap 'rm -f "${SPEC}"' EXIT
(
cd "${SCRIPT_DIR}/swagger-spec"
go run . "${API_DIR}/openapi.yaml" "${API_DIR}/swagger.yaml"
) > "${SPEC}"
chmod 644 "${SPEC}"
mv "${SPEC}" "${API_DIR}/swagger.yaml"
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"strings"
"go.yaml.in/yaml/v3"
)
// preserveJSONLayout restores multiline JSON objects and arrays from the source.
// yaml.Node retains flow style, but the YAML encoder discards its line breaks.
func preserveJSONLayout(source, output []byte, projected *yaml.Node) ([]byte, error) {
var emitted yaml.Node
if err := yaml.Unmarshal(output, &emitted); err != nil {
return nil, err
}
var result bytes.Buffer
position := 0
var walk func(*yaml.Node, *yaml.Node) error
walk = func(original, rendered *yaml.Node) error {
if original.Kind != rendered.Kind || len(original.Content) != len(rendered.Content) {
return errors.New("YAML encoder changed the projected structure")
}
src, sourceOK := jsonFragment(source, original)
dst, outputOK := jsonFragment(output, rendered)
if sourceOK && outputOK && bytes.Contains(src.text, []byte("\n")) {
// Source locations survive projection, so check the value as well:
// formatting must never restore data that projection changed.
var value yaml.Node
if err := yaml.Unmarshal(src.text, &value); err == nil && sameContent(value.Content[0], original) {
result.Write(output[position:dst.start])
lines := bytes.Split(src.text, []byte("\n"))
result.Write(lines[0])
sourceIndent := bytes.Repeat([]byte(" "), src.indent)
outputIndent := strings.Repeat(" ", dst.indent)
if original.Column == src.indent+1 {
// The encoder moved the opening delimiter beside the key.
// Keep continuation lines indented beyond that key.
outputIndent += " "
}
for _, line := range lines[1:] {
result.WriteByte('\n')
if len(bytes.TrimSpace(line)) != 0 {
result.WriteString(outputIndent)
result.Write(bytes.TrimPrefix(line, sourceIndent))
}
}
position = dst.start + len(dst.text)
return nil
}
}
for i, child := range original.Content {
if err := walk(child, rendered.Content[i]); err != nil {
return err
}
}
return nil
}
if err := walk(projected, emitted.Content[0]); err != nil {
return nil, err
}
result.Write(output[position:])
return result.Bytes(), nil
}
// sameContent includes mapping order, which matters to generated Go models.
func sameContent(a, b *yaml.Node) bool {
if a.Kind != b.Kind || a.Tag != b.Tag || a.Value != b.Value || len(a.Content) != len(b.Content) {
return false
}
for i, child := range a.Content {
if !sameContent(child, b.Content[i]) {
return false
}
}
return true
}
type jsonSource struct {
text []byte
start int
indent int
}
func jsonFragment(data []byte, node *yaml.Node) (jsonSource, bool) {
if node.Style&yaml.FlowStyle == 0 || node.Line < 1 || node.Column < 1 {
return jsonSource{}, false
}
lineStart := 0
for line := 1; line < node.Line; line++ {
end := bytes.IndexByte(data[lineStart:], '\n')
if end < 0 {
return jsonSource{}, false
}
lineStart += end + 1
}
line, _, _ := bytes.Cut(data[lineStart:], []byte("\n"))
// YAML columns count characters, not bytes.
runes := []rune(string(line))
if node.Column > len(runes) {
return jsonSource{}, false
}
start := lineStart + len(string(runes[:node.Column-1]))
if data[start] != '{' && data[start] != '[' {
return jsonSource{}, false
}
var raw json.RawMessage
decoder := json.NewDecoder(bytes.NewReader(data[start:]))
if err := decoder.Decode(&raw); err != nil {
return jsonSource{}, false
}
return jsonSource{
text: data[start : start+int(decoder.InputOffset())],
start: start,
indent: len(line) - len(bytes.TrimLeft(line, " ")),
}, true
}
+10
View File
@@ -0,0 +1,10 @@
module github.com/moby/moby/api/scripts/swagger-spec
go 1.24
require (
go.yaml.in/yaml/v3 v3.0.5
gotest.tools/v3 v3.5.2
)
require github.com/google/go-cmp v0.5.9 // indirect
+6
View File
@@ -0,0 +1,6 @@
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+159
View File
@@ -0,0 +1,159 @@
// swagger-spec projects the Engine OpenAPI specification to Swagger 2.0.
package main
import (
"bytes"
"cmp"
"errors"
"fmt"
"os"
"slices"
"strings"
"go.yaml.in/yaml/v3"
)
const swaggerHeader = `# A Swagger 2.0 (a.k.a. OpenAPI) definition of the Engine API.
#
# This is used for generating API documentation and the types used by the
# client/server. See api/README.md for more information.
#
# Some style notes:
# - This file is used by ReDoc, which allows GitHub Flavored Markdown in
# descriptions.
# - There is no maximum line length, for ease of editing and pretty diffs.
# - operationIds are in the format "NounVerb", with a singular noun.
# Code generated from openapi.yaml; DO NOT EDIT.
`
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) < 1 || len(args) > 2 {
return errors.New("usage: swagger-spec openapi.yaml [swagger.yaml]")
}
source, err := os.ReadFile(args[0]) // #nosec G703 -- intentionally using user-provided path.
if err != nil {
return err
}
var previous []byte
if len(args) == 2 {
previous, err = os.ReadFile(args[1]) // #nosec G703 -- intentionally using user-provided path.
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
result, err := render(source, previous)
if err != nil {
return err
}
_, err = os.Stdout.Write(result)
return err
}
func render(source, previous []byte) ([]byte, error) {
spec, err := parse(source)
if err != nil {
return nil, fmt.Errorf("parse OpenAPI: %w", err)
}
projected, err := project(spec)
if err != nil {
return nil, err
}
if len(previous) != 0 {
old, err := parse(previous)
if err != nil {
return nil, fmt.Errorf("parse previous Swagger: %w", err)
}
if equalNodes(comparisonSpec(old), comparisonSpec(projected)) {
return previous, nil
}
}
var buf bytes.Buffer
buf.WriteString(swaggerHeader)
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(projected); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return preserveJSONLayout(source, buf.Bytes(), projected)
}
// comparisonSpec ignores parameter placement and YAML status-key types, but not
// schema property order, which affects go-swagger's generated field order.
func comparisonSpec(spec *yaml.Node) *yaml.Node {
spec = clone(spec)
if definitions := get(spec, "definitions"); definitions != nil {
for i := 1; i < len(definitions.Content); i += 2 {
compareSchemaOrder(definitions.Content[i])
}
}
paths := get(spec, "paths")
if paths == nil {
return spec
}
for i := 1; i < len(paths.Content); i += 2 {
methods := paths.Content[i]
for j := 0; j < len(methods.Content); j += 2 {
if strings.HasPrefix(methods.Content[j].Value, "x-") {
continue
}
op := methods.Content[j+1]
if responses := get(op, "responses"); responses != nil {
for k := 0; k < len(responses.Content); k += 2 {
responses.Content[k] = scalar(responses.Content[k].Value)
compareSchemaOrder(get(responses.Content[k+1], "schema"))
}
}
parameters := get(op, "parameters")
if parameters == nil {
parameters = sequence()
set(op, "parameters", parameters)
}
for _, param := range parameters.Content {
compareSchemaOrder(get(param, "schema"))
}
slices.SortStableFunc(parameters.Content, func(a, b *yaml.Node) int {
if c := cmp.Compare(text(get(a, "in")), text(get(b, "in"))); c != 0 {
return c
}
return cmp.Compare(text(get(a, "name")), text(get(b, "name")))
})
}
}
return spec
}
func compareSchemaOrder(schema *yaml.Node) {
if schema == nil || schema.Kind != yaml.MappingNode {
return
}
if properties := get(schema, "properties"); properties != nil {
ordered := sequence()
for i := 0; i < len(properties.Content); i += 2 {
compareSchemaOrder(properties.Content[i+1])
pair := sequence()
pair.Content = properties.Content[i : i+2]
ordered.Content = append(ordered.Content, pair)
}
set(schema, "properties", ordered)
}
for _, key := range []string{"items", "additionalProperties"} {
compareSchemaOrder(get(schema, key))
}
if allOf := get(schema, "allOf"); allOf != nil {
for _, child := range allOf.Content {
compareSchemaOrder(child)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"testing"
"gotest.tools/v3/assert"
)
func TestComparisonIgnoresOnlyLayoutDifferences(t *testing.T) {
t.Parallel()
original := parseTest(t, `
definitions:
Thing:
properties:
first: {type: string}
second: {type: integer}
paths:
/test:
get:
parameters:
- {in: query, name: limit, type: integer}
- in: body
name: body
schema:
properties:
first: {type: string}
second: {type: integer}
responses:
200:
description: OK
schema:
properties:
first: {type: string}
second: {type: integer}
`)
for _, name := range []string{"parameter placement and status key type", "definition order", "body order", "response order"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
changed := clone(original)
op := get(changed, "paths", "/test", "get")
params := get(op, "parameters")
responses := get(op, "responses")
switch name {
case "parameter placement and status key type":
params.Content[0], params.Content[1] = params.Content[1], params.Content[0]
responses.Content[0] = scalar("200")
case "definition order":
properties := get(changed, "definitions", "Thing", "properties")
properties.Content = append(properties.Content[2:], properties.Content[:2]...)
case "body order":
properties := get(params.Content[1], "schema", "properties")
properties.Content = append(properties.Content[2:], properties.Content[:2]...)
case "response order":
properties := get(responses, "200", "schema", "properties")
properties.Content = append(properties.Content[2:], properties.Content[:2]...)
}
assert.Equal(t, equalNodes(comparisonSpec(changed), comparisonSpec(original)), name == "parameter placement and status key type")
})
}
}
func TestInvalidYAML(t *testing.T) {
t.Parallel()
for _, source := range []string{"", "[]", "key: one\nkey: two", "key: *missing", "key: &cycle {value: *cycle}", "key: one\n---\nkey: two"} {
t.Run(source, func(t *testing.T) {
t.Parallel()
_, err := parse([]byte(source))
assert.Assert(t, err != nil)
})
}
}
func TestRenderRejectsInvalidInput(t *testing.T) {
t.Parallel()
source := readSpec(t, "openapi")
_, err := render(source, []byte("invalid: ["))
assert.ErrorContains(t, err, "parse previous Swagger")
_, err = render([]byte("invalid: ["), nil)
assert.ErrorContains(t, err, "parse OpenAPI")
}
+271
View File
@@ -0,0 +1,271 @@
package main
import (
"errors"
"fmt"
"slices"
"strings"
"go.yaml.in/yaml/v3"
)
// project converts the Engine's supported OpenAPI subset, not arbitrary OpenAPI.
func project(spec *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(spec, "openapi", "jsonSchemaDialect", "servers", "info", "tags", "components", "paths"); err != nil {
return nil, err
}
if text(get(spec, "openapi")) != "3.2.0" {
return nil, errors.New("Swagger projection requires OpenAPI 3.2.0")
}
if text(get(spec, "jsonSchemaDialect")) != "https://spec.openapis.org/oas/3.1/dialect/base" {
return nil, errors.New("unsupported schema dialect")
}
if err := requireKeys(spec, "info", "tags"); err != nil {
return nil, err
}
servers := get(spec, "servers")
if servers == nil || servers.Kind != yaml.SequenceNode || len(servers.Content) != 1 || servers.Content[0].Kind != yaml.MappingNode || len(servers.Content[0].Content) != 2 {
return nil, errors.New("Swagger projection requires one relative server URL")
}
basePath := text(get(servers.Content[0], "url"))
if !strings.HasPrefix(basePath, "/") || strings.HasPrefix(basePath, "//") || strings.Contains(basePath, "{") {
return nil, errors.New("Swagger projection requires a relative server URL without variables")
}
components := get(spec, "components")
if err := checkKeys(components, "schemas"); err != nil {
return nil, fmt.Errorf("components: %w", err)
}
definitions, err := projectMapping(get(components, "schemas"), projectSchema)
if err != nil {
return nil, fmt.Errorf("schemas: %w", err)
}
result := mapping()
set(result, "swagger", scalar("2.0"))
// These legacy Swagger defaults are compatibility policy, not OpenAPI fields.
set(result, "schemes", sequence("http", "https"))
set(result, "produces", sequence("application/json", "text/plain"))
set(result, "consumes", sequence("application/json", "text/plain"))
set(result, "basePath", quotedScalar(basePath))
set(result, "info", get(spec, "info"))
set(result, "tags", get(spec, "tags"))
set(result, "definitions", definitions)
paths, err := projectMapping(get(spec, "paths"), func(item *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(item, "get", "put", "post", "delete", "options", "head", "patch"); err != nil {
return nil, err
}
methods := mapping()
for i := 0; i < len(item.Content); i += 2 {
method, value := item.Content[i].Value, item.Content[i+1]
if !strings.HasPrefix(method, "x-") {
var err error
value, err = projectOperation(value, result)
if err != nil {
return nil, fmt.Errorf("%s: %w", method, err)
}
}
set(methods, method, value)
}
return methods, nil
})
if err != nil {
return nil, fmt.Errorf("paths: %w", err)
}
set(result, "paths", paths)
return result, nil
}
func projectParameter(value *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(value, "name", "in", "description", "required", "schema", "style", "explode"); err != nil {
return nil, err
}
if err := requireKeys(value, "name", "in"); err != nil {
return nil, err
}
schema, err := projectSchema(get(value, "schema"))
if err != nil {
return nil, err
}
typ := text(get(schema, "type"))
if !slices.Contains([]string{"string", "integer", "number", "boolean", "array"}, typ) {
return nil, errors.New("Swagger non-body parameters require a primitive or array type")
}
result := without(value, "schema", "style", "explode")
for i := 0; i < len(schema.Content); i += 2 {
set(result, schema.Content[i].Value, schema.Content[i+1])
}
style, explode := get(value, "style"), get(value, "explode")
if typ == "array" {
if text(get(value, "in")) != "query" || (style != nil && style.Value != "form") {
return nil, errors.New("only form-style query arrays are supported")
}
format := "multi"
if explode != nil && explode.Tag == "!!bool" && explode.Value == "false" {
format = "csv"
}
set(result, "collectionFormat", quotedScalar(format))
} else if style != nil || explode != nil {
return nil, errors.New("serialization options are only supported on query arrays")
}
return result, nil
}
func contentSchema(content *yaml.Node, requestBody bool) (*yaml.Node, error) {
if content == nil || content.Kind != yaml.MappingNode || len(content.Content) == 0 {
return nil, errors.New("body content must be a nonempty mapping")
}
var schema *yaml.Node
for i := 0; i < len(content.Content); i += 2 {
media := content.Content[i+1]
if err := checkKeys(media, "schema", "example"); err != nil {
return nil, err
}
next := get(media, "schema")
// Swagger requests have only a schema example; responses have per-media examples.
if requestBody && next != nil && next.Kind == yaml.MappingNode {
next = without(next)
set(next, "example", get(media, "example"))
}
if i == 0 {
schema = next
} else if !equalNodes(schema, next) {
if requestBody {
return nil, errors.New("Swagger requires the same schema and effective example for all request media types")
}
return nil, errors.New("Swagger requires the same schema for all media types of a body")
}
}
if schema == nil {
return nil, nil
}
return projectSchema(schema)
}
func projectResponse(value *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(value, "description", "headers", "content"); err != nil {
return nil, err
}
if err := requireKeys(value, "description"); err != nil {
return nil, err
}
result := without(value, "headers", "content")
if headers := get(value, "headers"); headers != nil {
projected, err := projectMapping(headers, func(header *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(header, "description", "schema"); err != nil {
return nil, err
}
schema, err := projectSchema(get(header, "schema"))
if err != nil {
return nil, err
}
projectedHeader := without(header, "schema")
for i := 0; i < len(schema.Content); i += 2 {
set(projectedHeader, schema.Content[i].Value, schema.Content[i+1])
}
return projectedHeader, nil
})
if err != nil {
return nil, fmt.Errorf("headers: %w", err)
}
set(result, "headers", projected)
}
if content := get(value, "content"); content != nil {
schema, err := contentSchema(content, false)
if err != nil {
return nil, err
}
set(result, "schema", schema)
examples := mapping()
for i := 0; i < len(content.Content); i += 2 {
set(examples, content.Content[i].Value, get(content.Content[i+1], "example"))
}
if len(examples.Content) != 0 {
set(result, "examples", examples)
}
}
return result, nil
}
func projectOperation(value, defaults *yaml.Node) (*yaml.Node, error) {
if err := checkKeys(value, "summary", "description", "operationId", "tags", "parameters", "requestBody", "responses", "deprecated"); err != nil {
return nil, err
}
result := without(value, "requestBody", "responses", "parameters", "x-consumes")
parameters := sequence()
if params := get(value, "parameters"); params != nil {
if params.Kind != yaml.SequenceNode {
return nil, errors.New("parameters must be a sequence")
}
for _, param := range params.Content {
projected, err := projectParameter(param)
if err != nil {
return nil, fmt.Errorf("parameter %q: %w", text(get(param, "name")), err)
}
parameters.Content = append(parameters.Content, projected)
}
set(result, "parameters", parameters)
}
consumes := get(value, "x-consumes")
if consumes == nil {
consumes = get(defaults, "consumes")
}
if body := get(value, "requestBody"); body != nil {
if err := checkKeys(body, "description", "required", "content"); err != nil {
return nil, err
}
if err := requireKeys(body, "x-codegen-request-body-name"); err != nil {
return nil, err
}
if get(value, "x-consumes") != nil {
return nil, errors.New("requestBody and x-consumes cannot both specify media types")
}
content := get(body, "content")
schema, err := contentSchema(content, true)
if err != nil {
return nil, err
}
if schema == nil {
return nil, errors.New("request bodies require a schema")
}
consumes = sequence()
for i := 0; i < len(content.Content); i += 2 {
consumes.Content = append(consumes.Content, quotedScalar(content.Content[i].Value))
}
param := mapping()
set(param, "name", get(body, "x-codegen-request-body-name"))
set(param, "in", quotedScalar("body"))
set(param, "description", get(body, "description"))
set(param, "required", get(body, "required"))
set(param, "schema", schema)
parameters.Content = append(parameters.Content, param)
set(result, "parameters", parameters)
}
if !equalNodes(consumes, get(defaults, "consumes")) {
set(result, "consumes", consumes)
}
responses := get(value, "responses")
projected, err := projectMapping(responses, projectResponse)
if err != nil {
return nil, fmt.Errorf("responses: %w", err)
}
// Swagger has operation-wide media types, not per-response media types.
produces := sequence()
seen := make(map[string]bool)
for i := 1; i < len(responses.Content); i += 2 {
content := get(responses.Content[i], "content")
if content == nil {
continue
}
for j := 0; j < len(content.Content); j += 2 {
media := content.Content[j].Value
if !seen[media] {
produces.Content = append(produces.Content, quotedScalar(media))
seen[media] = true
}
}
}
if !equalNodes(produces, get(defaults, "produces")) {
set(result, "produces", produces)
}
set(result, "responses", projected)
return result, nil
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"bytes"
"os"
"testing"
"go.yaml.in/yaml/v3"
"gotest.tools/v3/assert"
)
func parseTest(t *testing.T, source string) *yaml.Node {
t.Helper()
n, err := parse([]byte(source))
assert.NilError(t, err)
return n
}
func readSpec(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile("../../" + name + ".yaml")
assert.NilError(t, err)
return data
}
func decoded(t *testing.T, n *yaml.Node) any {
t.Helper()
var value any
assert.NilError(t, n.Decode(&value))
return value
}
func assertYAML(t *testing.T, actual *yaml.Node, expected string) {
t.Helper()
assert.DeepEqual(t, decoded(t, actual), decoded(t, parseTest(t, expected)))
}
func mappingKeys(n *yaml.Node) []string {
var keys []string
if n != nil {
for i := 0; i < len(n.Content); i += 2 {
keys = append(keys, n.Content[i].Value)
}
}
return keys
}
func TestCheckedInSwagger(t *testing.T) {
t.Parallel()
source, previous := readSpec(t, "openapi"), readSpec(t, "swagger")
spec := parseTest(t, string(source))
original := clone(spec)
projected, err := project(spec)
assert.NilError(t, err)
assert.DeepEqual(t, decoded(t, comparisonSpec(projected)), decoded(t, comparisonSpec(parseTest(t, string(previous)))))
assert.DeepEqual(t, spec, original)
assert.DeepEqual(t, mappingKeys(get(projected, "paths")), mappingKeys(get(spec, "paths")))
for _, path := range mappingKeys(get(spec, "paths")) {
assert.DeepEqual(t, mappingKeys(get(projected, "paths", path)), mappingKeys(get(spec, "paths", path)))
}
schemas := get(spec, "components", "schemas")
assert.DeepEqual(t, mappingKeys(get(projected, "definitions")), mappingKeys(schemas))
for _, name := range mappingKeys(schemas) {
assert.DeepEqual(t, mappingKeys(get(projected, "definitions", name, "properties")), mappingKeys(get(schemas, name, "properties")))
}
unchanged, err := render(source, previous)
assert.NilError(t, err)
assert.Equal(t, string(unchanged), string(previous))
fresh, err := render(source, nil)
assert.NilError(t, err)
header, _, found := bytes.Cut(previous, []byte("\nswagger:"))
assert.Assert(t, found)
assert.Assert(t, bytes.HasPrefix(fresh, header))
assert.DeepEqual(t, decoded(t, comparisonSpec(parseTest(t, string(fresh)))), decoded(t, comparisonSpec(projected)))
}
func TestMultilineJSONExamples(t *testing.T) {
t.Parallel()
source := []byte(`openapi: 3.2.0
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base
servers:
- url: /v1.56
info:
title: Engine API
version: '1.56'
tags: []
components:
schemas:
Storage:
type: object
example:
{
"MergedDir": "/old/merged",
"é": {"brackets": "[}]", "quote": "\""},
"entries": [
{"value": 1},
{"value": 2}
]
}
Inline:
type: object
example: {
"value": "/old/merged"
}
paths: {}
`)
const example = ` example: {
"MergedDir": "/old/merged",
"é": {"brackets": "[}]", "quote": "\""},
"entries": [
{"value": 1},
{"value": 2}
]
}
`
const inlineExample = ` example: {
"value": "/old/merged"
}
`
previous, err := render(source, nil)
assert.NilError(t, err)
assert.Assert(t, bytes.Contains(previous, []byte(example)))
assert.Assert(t, bytes.Contains(previous, []byte(inlineExample)))
// Force regeneration, not the unchanged-document fast path.
source = bytes.ReplaceAll(source, []byte("/old/merged"), []byte("/new/merged"))
result, err := render(source, previous)
assert.NilError(t, err)
assert.Assert(t, bytes.Contains(result, bytes.ReplaceAll([]byte(example), []byte("/old/merged"), []byte("/new/merged"))))
assert.Assert(t, bytes.Contains(result, bytes.ReplaceAll([]byte(inlineExample), []byte("/old/merged"), []byte("/new/merged"))))
projected, err := project(parseTest(t, string(source)))
assert.NilError(t, err)
assert.DeepEqual(t, decoded(t, parseTest(t, string(result))), decoded(t, projected))
}
func TestOpaqueSchemaData(t *testing.T) {
t.Parallel()
schema := parseTest(t, `
type: object
example: &opaque {$ref: '#/components/schemas/NotAReference', nullable: true}
default: *opaque
x-custom: *opaque
additionalProperties: false
properties:
nullable: {type: boolean, x-nullable: false}
nested:
type: array
items:
allOf:
- {$ref: '#/components/schemas/Thing'}
`)
result, err := projectSchema(schema)
assert.NilError(t, err)
for _, key := range []string{"example", "default", "x-custom", "additionalProperties"} {
assert.DeepEqual(t, decoded(t, get(result, key)), decoded(t, get(schema, key)))
}
assertYAML(t, get(result, "properties", "nullable"), `{type: boolean, x-nullable: false}`)
assertYAML(t, get(result, "properties", "nested"), `{type: array, items: {allOf: [{$ref: '#/definitions/Thing'}]}}`)
}
func TestQueryArraySerialization(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, options, expected string }{
{"default", "", "multi"},
{"repeated", ", style: form, explode: true", "multi"},
{"comma separated", ", style: form, explode: false", "csv"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result, err := projectParameter(parseTest(t, `{name: names, in: query, schema: {type: array, items: {type: string}}`+tc.options+`}`))
assert.NilError(t, err)
assertYAML(t, result, `{name: names, in: query, type: array, items: {type: string}, collectionFormat: `+tc.expected+`}`)
})
}
}
func TestRequestAndResponseMediaTypes(t *testing.T) {
t.Parallel()
value := parseTest(t, `
requestBody:
x-codegen-request-body-name: archive
required: true
description: Archive to import
content:
application/x-tar:
schema: {type: string, format: binary}
responses:
200:
description: stream
content:
application/octet-stream: {}
400:
description: bad request
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorResponse'}
example: {message: bad archive}
text/plain:
schema: {$ref: '#/components/schemas/ErrorResponse'}
example: bad archive
`)
result, err := projectOperation(value, parseTest(t, `{consumes: [application/json], produces: [application/json]}`))
assert.NilError(t, err)
for field, expected := range map[string]string{
"consumes": "- \"application/x-tar\"\n",
"produces": "- \"application/octet-stream\"\n- \"application/json\"\n- \"text/plain\"\n",
} {
encoded, err := yaml.Marshal(get(result, field))
assert.NilError(t, err)
assert.Equal(t, string(encoded), expected)
}
assertYAML(t, result, `
consumes: [application/x-tar]
produces: [application/octet-stream, application/json, text/plain]
parameters:
- name: archive
in: body
required: true
description: Archive to import
schema: {type: string, format: binary}
responses:
"200": {description: stream}
"400":
description: bad request
schema: {$ref: '#/definitions/ErrorResponse'}
examples:
application/json: {message: bad archive}
text/plain: bad archive
`)
}
func TestRequestBodyExamples(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, content, schema string }{
{"media example", `{application/json: {schema: {type: object}, example: {name: demo}}}`, `{type: object, example: {name: demo}}`},
{"override schema example", `{application/json: {schema: {type: string, example: old}, example: new}}`, `{type: string, example: new}`},
{"matching effective examples", `{application/json: {schema: {type: string, example: old}, example: new}, text/plain: {schema: {type: string, example: new}}}`, `{type: string, example: new}`},
{"false override", `{application/json: {schema: {type: boolean, example: true}, example: false}}`, `{type: boolean, example: false}`},
{"null override", `{application/json: {schema: {type: object, example: {}}, example: null}}`, `{type: object, example: null}`},
{"different media examples", `{application/json: {schema: {type: string}, example: first}, text/plain: {schema: {type: string}, example: second}}`, ""},
{"conflicting fallback", `{application/json: {schema: {type: string, example: old}, example: new}, text/plain: {schema: {type: string, example: old}}}`, ""},
{"missing example", `{application/json: {schema: {type: string}, example: demo}, text/plain: {schema: {type: string}}}`, ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
value := parseTest(t, `{requestBody: {x-codegen-request-body-name: body, content: `+tc.content+`}, responses: {}}`)
original := clone(value)
result, err := projectOperation(value, parseTest(t, `{consumes: [], produces: []}`))
assert.DeepEqual(t, value, original)
if tc.schema == "" {
assert.ErrorContains(t, err, "same schema and effective example")
return
}
assert.NilError(t, err)
parameters := get(result, "parameters")
assert.Equal(t, len(parameters.Content), 1)
assertYAML(t, parameters.Content[0], `{name: body, in: body, schema: `+tc.schema+`}`)
})
}
}
func TestUnsupportedSchemas(t *testing.T) {
t.Parallel()
for _, key := range []string{"anyOf", "oneOf", "not", "const", "examples", "$defs", "discriminator", "nullable", "writeOnly", "deprecated"} {
t.Run(key, func(t *testing.T) {
t.Parallel()
_, err := projectSchema(parseTest(t, `{`+key+`: true}`))
assert.ErrorContains(t, err, "model projection does not support "+key)
})
}
_, err := projectSchema(parseTest(t, `{type: [object, 'null']}`))
assert.ErrorContains(t, err, "type unions")
_, err = projectSchema(parseTest(t, `{$ref: 'other.yaml#/Thing'}`))
assert.ErrorContains(t, err, "local schema reference")
}
func TestUnrepresentableContent(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, content, error string }{
{"different schemas", `{application/json: {schema: {type: object}}, text/plain: {schema: {type: string}}}`, "same schema"},
{"missing schema", `{application/json: {schema: {type: object}}, text/plain: {}}`, "same schema"},
{"streaming schema", `{application/json: {itemSchema: {type: object}}}`, "unsupported OpenAPI field"},
{"empty content", `{}`, "nonempty mapping"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := projectResponse(parseTest(t, `{description: response, content: `+tc.content+`}`))
assert.ErrorContains(t, err, tc.error)
})
}
}
func TestUnsupportedOperations(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, operation, error string }{
{"callbacks", `{callbacks: {}, responses: {}}`, "unsupported OpenAPI field"},
{"conflicting media", `{x-consumes: [], requestBody: {x-codegen-request-body-name: body, content: {}}, responses: {}}`, "cannot both specify"},
{"missing body schema", `{requestBody: {x-codegen-request-body-name: body, content: {text/plain: {}}}, responses: {}}`, "request bodies require a schema"},
{"missing responses", `{}`, "expected a mapping"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := projectOperation(parseTest(t, tc.operation), parseTest(t, `{consumes: [], produces: []}`))
assert.ErrorContains(t, err, tc.error)
})
}
}
func TestUnsupportedParameters(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, parameter, error string }{
{"object", `{name: n, in: query, schema: {type: object}}`, "primitive or array"},
{"header array", `{name: n, in: header, schema: {type: array}}`, "form-style query arrays"},
{"array style", `{name: n, in: query, style: spaceDelimited, schema: {type: array}}`, "form-style query arrays"},
{"scalar style", `{name: n, in: query, style: form, schema: {type: string}}`, "only supported on query arrays"},
{"scalar explode", `{name: n, in: query, explode: true, schema: {type: string}}`, "only supported on query arrays"},
{"missing schema", `{name: n, in: query}`, "missing schema"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := projectParameter(parseTest(t, tc.parameter))
assert.ErrorContains(t, err, tc.error)
})
}
}
func TestUnsupportedSpecification(t *testing.T) {
t.Parallel()
original := parseTest(t, `
openapi: 3.2.0
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base
servers: [{url: /v1}]
info: {}
tags: []
components: {schemas: {}}
paths: {}
`)
for _, tc := range []struct{ name, field, value, error string }{
{"version", "openapi", "3.0.3", "requires OpenAPI 3.2.0"},
{"dialect", "jsonSchemaDialect", "unknown", "unsupported schema dialect"},
{"absolute server", "servers", "[{url: 'https://example.com'}]", "relative server URL"},
{"network server", "servers", "[{url: '//example.com'}]", "relative server URL"},
{"variable server", "servers", "[{url: '/{version}'}]", "without variables"},
{"multiple servers", "servers", "[{url: /v1}, {url: /v2}]", "one relative server"},
{"components", "components", "{schemas: {}, parameters: {}}", "unsupported OpenAPI field"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
spec := clone(original)
set(spec, tc.field, get(parseTest(t, "value: "+tc.value), "value"))
_, err := project(spec)
assert.ErrorContains(t, err, tc.error)
})
}
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"errors"
"fmt"
"strings"
"go.yaml.in/yaml/v3"
)
func projectSchema(schema *yaml.Node) (*yaml.Node, error) {
if schema == nil {
return nil, errors.New("missing schema")
}
if schema.Kind == yaml.ScalarNode && schema.Tag == "!!bool" {
return schema, nil // additionalProperties
}
if schema.Kind != yaml.MappingNode {
return nil, errors.New("expected a schema mapping")
}
// Nullability uses x-nullable, not type unions or OpenAPI 3.0 nullable.
for _, key := range []string{"anyOf", "oneOf", "not", "const", "examples", "$defs", "discriminator", "nullable", "writeOnly", "deprecated"} {
if get(schema, key) != nil {
return nil, fmt.Errorf("model projection does not support %s", key)
}
}
if typ := get(schema, "type"); typ != nil && typ.Kind == yaml.SequenceNode {
return nil, errors.New("model projection does not support type unions")
}
result := without(schema)
if ref := get(schema, "$ref"); ref != nil {
const prefix = "#/components/schemas/"
if !strings.HasPrefix(ref.Value, prefix) {
return nil, fmt.Errorf("model projection requires a local schema reference: %q", ref.Value)
}
// go-swagger honors description and generator extensions beside $ref.
set(result, "$ref", quotedScalar("#/definitions/"+strings.TrimPrefix(ref.Value, prefix)))
}
if properties := get(schema, "properties"); properties != nil {
projected, err := projectMapping(properties, projectSchema)
if err != nil {
return nil, fmt.Errorf("properties: %w", err)
}
set(result, "properties", projected)
}
for _, key := range []string{"items", "additionalProperties"} {
if child := get(schema, key); child != nil {
projected, err := projectSchema(child)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
set(result, key, projected)
}
}
if allOf := get(schema, "allOf"); allOf != nil {
if allOf.Kind != yaml.SequenceNode {
return nil, errors.New("allOf must be a sequence")
}
projected := sequence()
for _, child := range allOf.Content {
value, err := projectSchema(child)
if err != nil {
return nil, fmt.Errorf("allOf: %w", err)
}
projected.Content = append(projected.Content, value)
}
set(result, "allOf", projected)
}
return result, nil
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"reflect"
"slices"
"strings"
"go.yaml.in/yaml/v3"
)
func parse(data []byte) (*yaml.Node, error) {
var doc yaml.Node
decoder := yaml.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&doc); err != nil {
return nil, err
}
var extra yaml.Node
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, errors.New("expected a single YAML document")
}
if len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode {
return nil, errors.New("expected a YAML mapping")
}
// Decode as well to detect duplicate keys and invalid or excessive aliases.
var value any
if err := doc.Decode(&value); err != nil {
return nil, err
}
return clone(doc.Content[0]), nil
}
func clone(n *yaml.Node) *yaml.Node {
if n == nil {
return nil
}
if n.Kind == yaml.AliasNode {
return clone(n.Alias)
}
result := *n
result.Anchor = ""
result.Content = make([]*yaml.Node, len(n.Content))
for i, child := range n.Content {
result.Content[i] = clone(child)
}
return &result
}
func mapping() *yaml.Node {
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
}
func sequence(values ...string) *yaml.Node {
result := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, value := range values {
result.Content = append(result.Content, quotedScalar(value))
}
return result
}
func scalar(s string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s}
}
func quotedScalar(s string) *yaml.Node {
n := scalar(s)
n.Style = yaml.DoubleQuotedStyle
return n
}
func get(n *yaml.Node, keys ...string) *yaml.Node {
for _, key := range keys {
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
var value *yaml.Node
for i := 0; i < len(n.Content); i += 2 {
if n.Content[i].Value == key {
value = n.Content[i+1]
break
}
}
n = value
}
return n
}
func text(n *yaml.Node) string {
if n == nil {
return ""
}
return n.Value
}
func set(n *yaml.Node, key string, value *yaml.Node) {
if value == nil {
return
}
for i := 0; i < len(n.Content); i += 2 {
if n.Content[i].Value == key {
n.Content[i+1] = value
return
}
}
n.Content = append(n.Content, scalar(key), value)
}
func without(n *yaml.Node, keys ...string) *yaml.Node {
result := mapping()
for i := 0; i < len(n.Content); i += 2 {
if !slices.Contains(keys, n.Content[i].Value) {
result.Content = append(result.Content, n.Content[i:i+2]...)
}
}
return result
}
func checkKeys(n *yaml.Node, allowed ...string) error {
if n == nil || n.Kind != yaml.MappingNode {
return errors.New("expected a mapping")
}
for i := 0; i < len(n.Content); i += 2 {
key := n.Content[i].Value
if !slices.Contains(allowed, key) && !strings.HasPrefix(key, "x-") {
return fmt.Errorf("unsupported OpenAPI field %q", key)
}
}
return nil
}
func requireKeys(n *yaml.Node, keys ...string) error {
for _, key := range keys {
if get(n, key) == nil {
return fmt.Errorf("missing field %q", key)
}
}
return nil
}
func projectMapping(n *yaml.Node, project func(*yaml.Node) (*yaml.Node, error)) (*yaml.Node, error) {
if n == nil || n.Kind != yaml.MappingNode {
return nil, errors.New("expected a mapping")
}
result := mapping()
for i := 0; i < len(n.Content); i += 2 {
key := n.Content[i].Value
value, err := project(n.Content[i+1])
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
set(result, key, value)
}
return result, nil
}
func equalNodes(a, b *yaml.Node) bool {
if a == nil || b == nil {
return a == b
}
var av, bv any
if a.Decode(&av) != nil || b.Decode(&bv) != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
+1
View File
@@ -20,6 +20,7 @@ for f in "${GEN_FILES[@]}"; do
cp "$f" "${TMP_DIR}/${f#${API_DIR}/}"
done
cp "${API_DIR}/openapi.yaml" "${TMP_DIR}/"
cp "${API_DIR}/swagger.yaml" "${TMP_DIR}/"
cp "${API_DIR}/swagger-gen.yaml" "${TMP_DIR}/"
cp "${API_DIR}/go.mod" "${TMP_DIR}/"
+14 -4
View File
@@ -4,14 +4,24 @@ set -e
# Expected to be in api directory
cd "$(dirname "${BASH_SOURCE[0]}")/.."
echo "Validating swagger.yaml..."
echo "Validating openapi.yaml..."
fy-tool --yaml-1.2 --null-output swagger.yaml
yamllint -f parsable -c validate/yamllint.yaml swagger.yaml
fy-tool --yaml-1.2 --null-output openapi.yaml
yamllint -f parsable -c validate/yamllint.yaml openapi.yaml
if out=$(swagger validate swagger.yaml); then
(
cd scripts/swagger-spec
# The test cache ignores the specifications outside this module.
go test -count=1 ./...
)
if out=$(openapi-spec-validator --schema 3.2 openapi.yaml); then
echo "Validation done! ${out}"
else
echo "${out}" >&2
false
fi
echo "Validating swagger.yaml..."
fy-tool --yaml-1.2 --null-output swagger.yaml
swagger validate swagger.yaml
+1641 -1841
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -17,6 +17,7 @@ api_diff=$(
':(exclude)api/Dockerfile' \
':(exclude)api/README.md' \
':(exclude)api/swagger.yaml' \
':(exclude)api/openapi.yaml' \
':(exclude)api/docs/' \
| filter_diff \
|| true