Merge pull request #53630 from vvoland/work-api
api: Move to OpenAPI 3.2 spec
This commit is contained in:
21 files changed
+18281
-1874
No files matched your search
+11
-1
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
Executable
+16
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}/"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
Reference in new issue
Block a user