vendor: update buildkit to v0.31.0-rc2

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2026-06-12 13:46:40 +02:00
parent d1c298cd85
commit 268dcf5917
351 changed files with 11926 additions and 4883 deletions
+4
View File
@@ -1,3 +1,7 @@
codecov:
notify:
after_n_builds: 2
coverage:
status:
patch:
+2 -1
View File
@@ -3,4 +3,5 @@
.idea
.env
.mcp.json
.claude/
go.work.sum
.worktrees
+1
View File
@@ -14,6 +14,7 @@ linters:
- recvcheck
- testpackage
- thelper
- tagliatelle
- tparallel
- varnamelen
- whitespace
+19 -12
View File
@@ -4,24 +4,31 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 15 | 202 |
| 22 | 267 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 99 | <https://github.com/go-openapi/analysis/commits?author=fredbi> |
| @casualjim | 70 | <https://github.com/go-openapi/analysis/commits?author=casualjim> |
| @fredbi | 127 | <https://github.com/go-openapi/analysis/commits?author=fredbi> |
| @casualjim | 91 | <https://github.com/go-openapi/analysis/commits?author=casualjim> |
| @keramix | 9 | <https://github.com/go-openapi/analysis/commits?author=keramix> |
| @youyuanwu | 8 | <https://github.com/go-openapi/analysis/commits?author=youyuanwu> |
| @msample | 3 | <https://github.com/go-openapi/analysis/commits?author=msample> |
| @wjase | 7 | <https://github.com/go-openapi/analysis/commits?author=wjase> |
| @kul-amr | 3 | <https://github.com/go-openapi/analysis/commits?author=kul-amr> |
| @schafle | 3 | <https://github.com/go-openapi/analysis/commits?author=schafle> |
| @msample | 3 | <https://github.com/go-openapi/analysis/commits?author=msample> |
| @mbohlool | 2 | <https://github.com/go-openapi/analysis/commits?author=mbohlool> |
| @Copilot | 1 | <https://github.com/go-openapi/analysis/commits?author=Copilot> |
| @danielfbm | 1 | <https://github.com/go-openapi/analysis/commits?author=danielfbm> |
| @gregmarr | 1 | <https://github.com/go-openapi/analysis/commits?author=gregmarr> |
| @guillemj | 1 | <https://github.com/go-openapi/analysis/commits?author=guillemj> |
| @knweiss | 1 | <https://github.com/go-openapi/analysis/commits?author=knweiss> |
| @tklauser | 1 | <https://github.com/go-openapi/analysis/commits?author=tklauser> |
| @cuishuang | 1 | <https://github.com/go-openapi/analysis/commits?author=cuishuang> |
| @zmay2030 | 2 | <https://github.com/go-openapi/analysis/commits?author=zmay2030> |
| @ujjwalsh | 1 | <https://github.com/go-openapi/analysis/commits?author=ujjwalsh> |
| @itengfei | 1 | <https://github.com/go-openapi/analysis/commits?author=itengfei> |
| @nrnrk | 1 | <https://github.com/go-openapi/analysis/commits?author=nrnrk> |
| @cuishuang | 1 | <https://github.com/go-openapi/analysis/commits?author=cuishuang> |
| @tklauser | 1 | <https://github.com/go-openapi/analysis/commits?author=tklauser> |
| @Shimizu1111 | 1 | <https://github.com/go-openapi/analysis/commits?author=Shimizu1111> |
| @thaJeztah | 1 | <https://github.com/go-openapi/analysis/commits?author=thaJeztah> |
| @knweiss | 1 | <https://github.com/go-openapi/analysis/commits?author=knweiss> |
| @guillemj | 1 | <https://github.com/go-openapi/analysis/commits?author=guillemj> |
| @gregmarr | 1 | <https://github.com/go-openapi/analysis/commits?author=gregmarr> |
| @danielfbm | 1 | <https://github.com/go-openapi/analysis/commits?author=danielfbm> |
| @Copilot | 1 | <https://github.com/go-openapi/analysis/commits?author=Copilot> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+3 -2
View File
@@ -12,7 +12,7 @@
---
A foundational library to analyze an OAI specification document for easier reasoning about the content.
A foundational library to analyze, diff, flatten, merge, and fix OAI specification documents for easier reasoning about the content.
## Announcements
@@ -38,6 +38,7 @@ go get github.com/go-openapi/analysis
* An analyzer providing methods to walk the functional content of a specification
* A spec flattener producing a self-contained document bundle, while preserving `$ref`s
* A spec differ ("diff") to compare two specs and report structural and compatibility changes
* A spec merger ("mixin") to merge several spec documents into a primary spec
* A spec "fixer" ensuring that response descriptions are non empty
@@ -115,7 +116,7 @@ Maintainers can cut a new release by either:
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
+24 -17
View File
@@ -145,19 +145,27 @@ type Spec struct {
enums enumAnalysis
allSchemas map[string]SchemaRef
allOfs map[string]SchemaRef
mangler mangling.NameMangler
}
// New takes a swagger spec object and returns an analyzed spec document.
// The analyzed document contains a number of indices that make it easier to
// reason about semantics of a swagger specification for use in code generation
// or validation etc.
func New(doc *spec.Swagger) *Spec {
func New(doc *spec.Swagger, opts ...Option) *Spec {
o := &analyzerOptions{}
for _, opt := range opts {
opt(o)
}
a := &Spec{
spec: doc,
references: referenceAnalysis{},
patterns: patternAnalysis{},
enums: enumAnalysis{},
mangler: mangling.NewNameMangler(o.manglerOpts...),
}
a.reset()
a.initialize()
@@ -288,20 +296,6 @@ func (s *Spec) ProducesFor(operation *spec.Operation) []string {
return s.structMapKeys(prod)
}
func mapKeyFromParam(param *spec.Parameter) string {
return fmt.Sprintf("%s#%s", param.In, fieldNameFromParam(param))
}
func fieldNameFromParam(param *spec.Parameter) string {
// TODO: this should be x-go-name
if nm, ok := param.Extensions.GetString("go-name"); ok {
return nm
}
mangler := mangling.NewNameMangler()
return mangler.ToGoName(param.Name)
}
// ErrorOnParamFunc is a callback function to be invoked
// whenever an error is encountered while resolving references
// on parameters.
@@ -651,6 +645,19 @@ func (s *Spec) AllEnums() map[string][]any {
return cloneEnumMap(s.enums.allEnums)
}
func (s *Spec) mapKeyFromParam(param *spec.Parameter) string {
return fmt.Sprintf("%s#%s", param.In, s.fieldNameFromParam(param))
}
func (s *Spec) fieldNameFromParam(param *spec.Parameter) string {
// TODO: this should be x-go-name
if nm, ok := param.Extensions.GetString("go-name"); ok {
return nm
}
return s.mangler.ToGoName(param.Name)
}
func (s *Spec) structMapKeys(mp map[string]struct{}) []string {
if len(mp) == 0 {
return nil
@@ -668,7 +675,7 @@ func (s *Spec) paramsAsMap(parameters []spec.Parameter, res map[string]spec.Para
for _, param := range parameters {
pr := param
if pr.Ref.String() == "" {
res[mapKeyFromParam(&pr)] = pr
res[s.mapKeyFromParam(&pr)] = pr
continue
}
@@ -699,7 +706,7 @@ func (s *Spec) paramsAsMap(parameters []spec.Parameter, res map[string]spec.Para
}
pr = objAsParam
res[mapKeyFromParam(&pr)] = pr
res[s.mapKeyFromParam(&pr)] = pr
}
}
+1
View File
@@ -554,6 +554,7 @@ func updateRefParents(allRefs map[string]spec.Ref, r *newRef) {
}
}
//nolint:gocognit,gocyclo,cyclop // legacy from a lot of design choices that led to concentrate the complexity just here.
func stripOAIGenForRef(opts *FlattenOpts, k string, r *newRef) (bool, error) {
replacedWithComplex := false
+2 -2
View File
@@ -273,9 +273,9 @@ func mangler(o *FlattenOpts) func(string) string {
if o.KeepNames {
return func(in string) string { return in }
}
mangler := mangling.NewNameMangler()
m := mangling.NewNameMangler(o.ManglerOpts...)
return mangler.ToJSONName
return m.ToJSONName
}
func nameFromRef(ref spec.Ref, o *FlattenOpts) string {
+8 -6
View File
@@ -7,6 +7,7 @@ import (
"log"
"github.com/go-openapi/spec"
"github.com/go-openapi/swag/mangling"
)
// FlattenOpts configuration for flattening a swagger specification.
@@ -24,12 +25,13 @@ type FlattenOpts struct {
BasePath string // The location of the root document for this spec to resolve relative $ref
// Flattening options
Expand bool // When true, skip flattening the spec and expand it instead (if Minimal is false)
Minimal bool // When true, do not decompose complex structures such as allOf
Verbose bool // enable some reporting on possible name conflicts detected
RemoveUnused bool // When true, remove unused parameters, responses and definitions after expansion/flattening
ContinueOnError bool // Continue when spec expansion issues are found
KeepNames bool // Do not attempt to jsonify names from references when flattening
Expand bool // When true, skip flattening the spec and expand it instead (if Minimal is false)
Minimal bool // When true, do not decompose complex structures such as allOf
Verbose bool // enable some reporting on possible name conflicts detected
RemoveUnused bool // When true, remove unused parameters, responses and definitions after expansion/flattening
ContinueOnError bool // Continue when spec expansion issues are found
KeepNames bool // Do not attempt to jsonify names from references when flattening
ManglerOpts []mangling.Option // Options for the name mangler used to jsonify names
/* Extra keys */
_ struct{} // require keys
+1 -1
View File
@@ -1,4 +1,4 @@
go 1.24.0
go 1.25.0
use (
.
-29
View File
@@ -1,29 +0,0 @@
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+50 -21
View File
@@ -11,37 +11,66 @@ import (
"github.com/go-openapi/spec"
)
// Mixin modifies the primary swagger spec by adding the paths and
// definitions from the mixin specs. Top level parameters and
// responses from the mixins are also carried over. Operation id
// collisions are avoided by appending "Mixin<N>" but only if
// needed.
// Mixin merges one or more Swagger 2.0 documents into a primary document.
//
// The following parts of primary are subject to merge, filling empty details
// # Argument order and precedence
//
// - Info
// The first argument is the primary spec, which Mixin modifies in place.
// Subsequent arguments are mixins, listed in decreasing order of priority.
// On any collision, the primary always wins; among mixins, the earliest one
// wins.
//
// Example: given a primary spec with host "a.example.com" and a mixin with
// host "b.example.com", the merged result keeps "a.example.com" (primary
// wins, the mixin value is dropped). Given a primary without a host and a
// mixin with host "b.example.com", the merged result uses "b.example.com"
// (the mixin fills in the empty field on the primary).
//
// # What gets merged
//
// Top-level scalar fields on the primary are filled from the first mixin
// that provides them, but only if the primary's value is the zero value:
//
// - Info (including the nested Contact and License)
// - BasePath
// - Host
// - ExternalDocs
//
// Consider calling [FixEmptyResponseDescriptions]() on the modified primary
// if you read them from storage and they are valid to start with.
// Map and slice fields are merged entry by entry. This covers:
//
// Entries in "paths", "definitions", "parameters" and "responses" are
// added to the primary in the order of the given mixins. If the entry
// already exists in primary it is skipped with a warning message.
// - paths, definitions, parameters, responses
// - securityDefinitions, security, tags
// - top-level and Info extensions
//
// The count of skipped entries (from collisions) is returned so any
// deviation from the number expected can flag a warning in your build
// scripts. Carefully review the collisions before accepting them;
// consider renaming things if possible.
// Duplicate keys (or equal security requirements, or equal tag names) are
// skipped with a warning; warnings are returned as a slice and intended to
// be inspected by the caller (e.g. compared to an expected collision count
// in build scripts).
//
// No key normalization takes place (paths, type defs,
// etc). Ensure they are canonical if your downstream tools do
// key normalization of any form.
// Schemes, consumes and produces are merged as the union of distinct
// values. Duplicates there are silently dropped, no warning is emitted.
//
// Merging schemes ([http], https), and consumers/producers do not account for
// collisions.
// Operation id collisions are auto-resolved by appending "Mixin<N>" to the
// mixin operation id (N is the mixin index), so the merged spec keeps
// unique operation ids.
//
// # Notes and limitations
//
// Consider calling [FixEmptyResponseDescriptions] on the modified primary
// if you read responses from storage and they are valid to start with.
//
// No key normalization takes place. Ensure paths, type names, etc. are
// canonical if your downstream tools rely on normalized forms.
//
// YAML anchors (& / *) are resolved by the YAML parser before Mixin sees
// the document, so they are not preserved in the merged output, and they
// cannot be shared across input files. Use $ref for cross-file reuse. See
// https://goswagger.io/go-swagger/faq/faq_swagger/#does-swagger-mixin-preserve-yaml-anchors
//
// The order of paths and definitions in the merged output is alphabetical:
// the underlying spec model stores them as Go maps, which serialize with
// sorted keys. Source-file order is not preserved. See
// https://goswagger.io/go-swagger/faq/faq_swagger/#can-i-control-the-path-or-operation-order-in-swagger-mixin-output
func Mixin(primary *spec.Swagger, mixins ...*spec.Swagger) []string {
skipped := make([]string, 0, len(mixins))
opIDs := getOpIDs(primary)
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package analysis
import "github.com/go-openapi/swag/mangling"
// Option configures the behavior of a new [Spec] analyzer.
type Option func(*analyzerOptions)
type analyzerOptions struct {
manglerOpts []mangling.Option
}
// WithManglerOptions sets the name mangler options used when building
// Go identifiers from specification names (e.g. parameter names).
func WithManglerOptions(opts ...mangling.Option) Option {
return func(o *analyzerOptions) {
o.manglerOpts = append(o.manglerOpts, opts...)
}
}
-181
View File
@@ -1,181 +0,0 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
header = """
"""
footer = """
-----
**[{{ remote.github.repo }}]({{ self::remote_url() }}) license terms**
[![License][license-badge]][license-url]
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
[license-url]: {{ self::remote_url() }}/?tab=Apache-2.0-1-ov-file#readme
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
"""
body = """
{%- if version %}
## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/tree/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
{%- else %}
## [unreleased]
{%- endif %}
{%- if message %}
{%- raw %}\n{% endraw %}
{{ message }}
{%- raw %}\n{% endraw %}
{%- endif %}
{%- if version %}
{%- if previous.version %}
**Full Changelog**: <{{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}>
{%- endif %}
{%- else %}
{%- raw %}\n{% endraw %}
{%- endif %}
{%- if statistics %}{% if statistics.commit_count %}
{%- raw %}\n{% endraw %}
{{ statistics.commit_count }} commits in this release.
{%- raw %}\n{% endraw %}
{%- endif %}{% endif %}
-----
{%- for group, commits in commits | group_by(attribute="group") %}
{%- raw %}\n{% endraw %}
### {{ group | upper_first }}
{%- raw %}\n{% endraw %}
{%- for commit in commits %}
{%- if commit.remote.pr_title %}
{%- set commit_message = commit.remote.pr_title %}
{%- else %}
{%- set commit_message = commit.message %}
{%- endif %}
* {{ commit_message | split(pat="\n") | first | trim }}
{%- if commit.remote.username %}
{%- raw %} {% endraw %}by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }})
{%- endif %}
{%- if commit.remote.pr_number %}
{%- raw %} {% endraw %}in [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }})
{%- endif %}
{%- raw %} {% endraw %}[...]({{ self::remote_url() }}/commit/{{ commit.id }})
{%- endfor %}
{%- endfor %}
{%- if github %}
{%- raw %}\n{% endraw -%}
{%- set all_contributors = github.contributors | length %}
{%- if github.contributors | filter(attribute="username", value="dependabot[bot]") | length < all_contributors %}
-----
### People who contributed to this release
{% endif %}
{%- for contributor in github.contributors | filter(attribute="username") | sort(attribute="username") %}
{%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %}
* [@{{ contributor.username }}](https://github.com/{{ contributor.username }})
{%- endif %}
{%- endfor %}
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
-----
{%- raw %}\n{% endraw %}
### New Contributors
{%- endif %}
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
{%- if contributor.username != "dependabot[bot]" and contributor.username != "github-actions[bot]" %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %}
in [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- raw %}\n{% endraw %}
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
# Render body even when there are no releases to process.
render_always = true
# An array of regex based postprocessors to modify the changelog.
postprocessors = [
# Replace the placeholder <REPO> with a URL.
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
]
# output file path
# output = "test.md"
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = false
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = false
# Require all commits to be conventional.
# Takes precedence over filter_unconventional.
require_conventional = false
# Split commits on newlines, treating each line as an individual commit.
split_commits = false
# An array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit message using https://github.com/crate-ci/typos.
# If the spelling is incorrect, it will be fixed automatically.
#{ pattern = '.*', replace_command = 'typos --write-changes -' }
]
# Prevent commits that are breaking from being excluded by commit parsers.
protect_breaking_commits = false
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^[Cc]hore\\([Rr]elease\\): prepare for", skip = true },
{ message = "(^[Mm]erge)|([Mm]erge conflict)", skip = true },
{ field = "author.name", pattern = "dependabot*", group = "<!-- 0A -->Updates" },
{ message = "([Ss]ecurity)|([Vv]uln)", group = "<!-- 08 -->Security" },
{ body = "(.*[Ss]ecurity)|([Vv]uln)", group = "<!-- 08 -->Security" },
{ message = "([Cc]hore\\(lint\\))|(style)|(lint)|(codeql)|(golangci)", group = "<!-- 05 -->Code quality" },
{ message = "(^[Dd]oc)|((?i)readme)|(badge)|(typo)|(documentation)", group = "<!-- 03 -->Documentation" },
{ message = "(^[Ff]eat)|(^[Ee]nhancement)", group = "<!-- 00 -->Implemented enhancements" },
{ message = "(^ci)|(\\(ci\\))|(fixup\\s+ci)|(fix\\s+ci)|(license)|(example)", group = "<!-- 07 -->Miscellaneous tasks" },
{ message = "^test", group = "<!-- 06 -->Testing" },
{ message = "(^fix)|(panic)", group = "<!-- 01 -->Fixed bugs" },
{ message = "(^refact)|(rework)", group = "<!-- 02 -->Refactor" },
{ message = "(^[Pp]erf)|(performance)", group = "<!-- 04 -->Performance" },
{ message = "(^[Cc]hore)", group = "<!-- 07 -->Miscellaneous tasks" },
{ message = "^[Rr]evert", group = "<!-- 09 -->Reverted changes" },
{ message = "(upgrade.*?go)|(go\\s+version)", group = "<!-- 0A -->Updates" },
{ message = ".*", group = "<!-- 0B -->Other" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
link_parsers = []
# Include only the tags that belong to the current branch.
use_branch_tags = false
# Order releases topologically instead of chronologically.
topo_order = false
# Order releases topologically instead of chronologically.
topo_order_commits = true
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "newest"
# Process submodules commits
recurse_submodules = false
#[remote.github]
#owner = "go-openapi"
-1
View File
@@ -3,4 +3,3 @@
.idea
.env
.mcp.json
.claude/
+4 -3
View File
@@ -4,11 +4,11 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 12 | 101 |
| 13 | 111 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 54 | <https://github.com/go-openapi/jsonpointer/commits?author=fredbi> |
| @fredbi | 63 | <https://github.com/go-openapi/jsonpointer/commits?author=fredbi> |
| @casualjim | 33 | <https://github.com/go-openapi/jsonpointer/commits?author=casualjim> |
| @magodo | 3 | <https://github.com/go-openapi/jsonpointer/commits?author=magodo> |
| @youyuanwu | 3 | <https://github.com/go-openapi/jsonpointer/commits?author=youyuanwu> |
@@ -18,7 +18,8 @@
| @ianlancetaylor | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=ianlancetaylor> |
| @mfleader | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=mfleader> |
| @Neo2308 | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=Neo2308> |
| @alexandear | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=alexandear> |
| @olivierlemasle | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=olivierlemasle> |
| @testwill | 1 | <https://github.com/go-openapi/jsonpointer/commits?author=testwill> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+1 -1
View File
@@ -18,7 +18,7 @@ It ships with copies of other software which license terms are recalled below.
The original software was authored on 25-02-2013 by sigu-399 (https://github.com/sigu-399, sigu.399@gmail.com).
github.com/sigh-399/jsonpointer
github.com/sigu-399/jsonpointer
===========================
// SPDX-FileCopyrightText: Copyright 2013 sigu-399 ( https://github.com/sigu-399 )
+31 -21
View File
@@ -16,17 +16,25 @@ An implementation of JSON Pointer for golang, which supports go `struct`.
## Announcements
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
* **2026-04-15** : added support for trailing "-" for arrays (v0.23.0)
* this brings full support of [RFC6901][RFC6901]
* this is supported for types relying on the reflection-based implemented
* API semantics remain essentially unaltered. Exception: `Pointer.Set(document any,value any) (document any, err error)`
can only perform a best-effort to mutate the input document in place. In the case of adding elements to an array with a
trailing "-", either pass a mutable array (`*[]T`) as the input document, or use the returned updated document instead.
* types that implement the `JSONSetable` interface may not implement the mutation implied by the trailing "-"
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
* **2026-04-15** : added support for optional alternate JSON name providers
* for struct support the defaults might not suit all situations: there are known limitations
when it comes to handle untagged fields or embedded types.
* the default name provider in use is not fully aligned with go JSON stdlib
* exposed an option (or global setting) to change the provider that resolves a struct into json keys
* the default behavior is not altered
* a new alternate name provider is added (imported from `go-openapi/swag/jsonname`), aligned with JSON stdlib behavior
## Status
API is stable.
API is stable and feature-complete.
## Import this library in your project
@@ -88,7 +96,7 @@ See <https://github.com/go-openapi/jsonpointer/releases>
<https://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-07>
also known as [RFC6901](https://www.rfc-editor.org/rfc/rfc6901)
also known as [RFC6901][RFC6901].
## Licensing
@@ -99,19 +107,19 @@ on top of which it has been built.
## Limitations
The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array,
the reference token MUST contain either...' is not implemented.
That is because our implementation of the JSON pointer only supports explicit references to array elements:
the provision in the spec to resolve non-existent members as "the last element in the array",
using the special trailing character "-" is not implemented.
* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations).
* Default behavior: JSON name detection in go `struct`s
- Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored.
- anonymous fields are not traversed if untagged
- the above limitations may be overcome by calling `UseGoNameProvider()` at initialization time.
- alternatively, users may inject the desired custom behavior for naming fields as an option.
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -142,11 +150,8 @@ Maintainers can cut a new release by either:
<!-- Badges: documentation & support -->
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/jsonpointer
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/jsonpointer
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
@@ -156,3 +161,8 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest
[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+25 -1
View File
@@ -16,12 +16,24 @@ const (
ErrPointer pointerError = "JSON pointer error"
// ErrInvalidStart states that a JSON pointer must start with a separator ("/").
ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator
ErrInvalidStart pointerError = `JSON pointer must be empty or start with a "` + pointerSeparator + `"`
// ErrUnsupportedValueType indicates that a value of the wrong type is being set.
ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values"
// ErrDashToken indicates use of the RFC 6901 "-" reference token
// in a context where it cannot be resolved.
//
// Per RFC 6901 §4 the "-" token refers to the (nonexistent) element
// after the last array element. It may only be used as the terminal
// token of a [Pointer.Set] against a slice, where it means "append".
// Any other use (get, offset, intermediate traversal, non-slice target)
// is an error condition that wraps this sentinel.
ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential.
)
const dashToken = "-"
func errNoKey(key string) error {
return fmt.Errorf("object has no key %q: %w", key, ErrPointer)
}
@@ -33,3 +45,15 @@ func errOutOfBounds(length, idx int) error {
func errInvalidReference(token string) error {
return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer)
}
func errDashOnGet() error {
return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer)
}
func errDashIntermediate() error {
return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer)
}
func errDashOnOffset() error {
return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer)
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package jsonpointer
import "reflect"
// JSONPointable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
type JSONPointable interface {
// JSONLookup returns a value pointed at this (unescaped) key.
JSONLookup(key string) (any, error)
}
// JSONSetable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
//
// # Handling of the RFC 6901 "-" token
//
// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set]
// call, the library passes the raw reference token to JSONSet without
// interpretation. In particular, the RFC 6901 "-" token (which conventionally
// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key
// argument. Implementations that model an array-like container are expected
// to give "-" the append semantics; implementations that do not should return
// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity.
//
// Implementations are responsible for any in-place mutation: the library does
// not attempt to rebind the result of JSONSet into a parent container.
type JSONSetable interface {
// JSONSet sets the value pointed at the (unescaped) key.
//
// The key may be the RFC 6901 "-" token when the pointer targets a
// slice-like member; see the interface documentation for details.
JSONSet(key string, value any) error
}
// NameProvider knows how to resolve go struct fields into json names.
//
// The default provider is brought by [github.com/go-openapi/swag/jsonname.DefaultJSONNameProvider].
type NameProvider interface {
// GetGoName gets the go name for a json property name
GetGoName(subject any, name string) (string, bool)
// GetGoNameForType gets the go name for a given type for a json property name
GetGoNameForType(tpe reflect.Type, name string) (string, bool)
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package jsonpointer
import (
"sync"
"github.com/go-openapi/swag/jsonname"
)
// Option to tune the behavior of a JSON [Pointer].
type Option func(*options)
var (
//nolint:gochecknoglobals // package level defaults are provided as a convenient, backward-compatible way to adopt options.
defaultOptions = options{
provider: jsonname.DefaultJSONNameProvider,
}
//nolint:gochecknoglobals // guards defaultOptions against concurrent SetDefaultNameProvider / read races (testing)
defaultOptionsMu sync.RWMutex
)
// SetDefaultNameProvider sets the [NameProvider] as a package-level default.
//
// By default, the default provider is [jsonname.DefaultJSONNameProvider].
//
// It is safe to call concurrently with [Pointer.Get], [Pointer.Set],
// [GetForToken] and [SetForToken]. The typical usage is to call it once
// at initialization time.
//
// A nil provider is ignored.
func SetDefaultNameProvider(provider NameProvider) {
if provider == nil {
return
}
defaultOptionsMu.Lock()
defer defaultOptionsMu.Unlock()
defaultOptions.provider = provider
}
// UseGoNameProvider sets the [NameProvider] as a package-level default
// to the alternative provider [jsonname.GoNameProvider], that covers a few areas
// not supported by the default name provider.
//
// This implementation supports untagged exported fields and embedded types in go struct.
// It follows strictly the behavior of the JSON standard library regarding field naming conventions.
//
// It is safe to call concurrently with [Pointer.Get], [Pointer.Set],
// [GetForToken] and [SetForToken]. The typical usage is to call it once
// at initialization time.
func UseGoNameProvider() {
SetDefaultNameProvider(jsonname.NewGoNameProvider())
}
// DefaultNameProvider returns the current package-level [NameProvider].
func DefaultNameProvider() NameProvider { //nolint:ireturn // returning the interface is the point — callers pick their own implementation.
defaultOptionsMu.RLock()
defer defaultOptionsMu.RUnlock()
return defaultOptions.provider
}
// WithNameProvider injects a custom [NameProvider] to resolve json names from go struct types.
func WithNameProvider(provider NameProvider) Option {
return func(o *options) {
o.provider = provider
}
}
type options struct {
provider NameProvider
}
func optionsWithDefaults(opts []Option) options {
var o options
o.provider = DefaultNameProvider()
for _, apply := range opts {
apply(&o)
}
return o
}
+273 -85
View File
@@ -11,8 +11,6 @@ import (
"reflect"
"strconv"
"strings"
"github.com/go-openapi/swag/jsonname"
)
const (
@@ -20,20 +18,6 @@ const (
pointerSeparator = `/`
)
// JSONPointable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
type JSONPointable interface {
// JSONLookup returns a value pointed at this (unescaped) key.
JSONLookup(key string) (any, error)
}
// JSONSetable is an interface for structs to implement,
// when they need to customize the json pointer process or want to avoid the use of reflection.
type JSONSetable interface {
// JSONSet sets the value pointed at the (unescaped) key.
JSONSet(key string, value any) error
}
// Pointer is a representation of a json pointer.
//
// Use [Pointer.Get] to retrieve a value or [Pointer.Set] to set a value.
@@ -41,7 +25,7 @@ type JSONSetable interface {
// It works with any go type interpreted as a JSON document, which means:
//
// - if a type implements [JSONPointable], its [JSONPointable.JSONLookup] method is used to resolve [Pointer.Get]
// - if a type implements [JSONSetable], its [JSONPointable.JSONSet] method is used to resolve [Pointer.Set]
// - if a type implements [JSONSetable], its [JSONSetable.JSONSet] method is used to resolve [Pointer.Set]
// - a go map[K]V is interpreted as an object, with type K assignable to a string
// - a go slice []T is interpreted as an array
// - a go struct is interpreted as an object, with exported fields interpreted as keys
@@ -71,16 +55,35 @@ func New(jsonPointerString string) (Pointer, error) {
// Get uses the pointer to retrieve a value from a JSON document.
//
// It returns the value with its type as a [reflect.Kind] or an error.
func (p *Pointer) Get(document any) (any, reflect.Kind, error) {
return p.get(document, jsonname.DefaultJSONNameProvider)
func (p *Pointer) Get(document any, opts ...Option) (any, reflect.Kind, error) {
o := optionsWithDefaults(opts)
return p.get(document, o.provider)
}
// Set uses the pointer to set a value from a data type
// that represent a JSON document.
//
// It returns the updated document.
func (p *Pointer) Set(document any, value any) (any, error) {
return document, p.set(document, value, jsonname.DefaultJSONNameProvider)
// # Mutation contract
//
// Set mutates the provided document in place whenever Go's type system allows
// it: when document is a map, a pointer, or when the targeted value is reached
// through an addressable ancestor (e.g. a struct field traversed via a pointer,
// a slice element). Callers that rely on this in-place behavior may continue
// to ignore the returned document.
//
// The returned document is only load-bearing when Set cannot mutate in place.
// This happens in one specific case: appending to a top-level slice passed by
// value (e.g. document of type []T rather than *[]T) via the RFC 6901 "-"
// terminal token. reflect.Append produces a new slice header that the library
// cannot rebind into the caller's variable; the updated document is returned
// instead. Pass *[]T if you want in-place rebind for that case as well.
//
// See [ErrDashToken] for the semantics of the "-" token.
func (p *Pointer) Set(document any, value any, opts ...Option) (any, error) {
o := optionsWithDefaults(opts)
return p.set(document, value, o.provider)
}
// DecodedTokens returns the decoded (unescaped) tokens of this JSON pointer.
@@ -109,6 +112,46 @@ func (p *Pointer) String() string {
return pointerSeparator + strings.Join(p.referenceTokens, pointerSeparator)
}
// Offset returns the byte offset, in the raw JSON text of document, of the
// location referenced by this pointer's terminal token.
//
// Unlike [Pointer.Get] and [Pointer.Set], which operate on a decoded Go value,
// Offset operates directly on the textual JSON source. It drives an
// [encoding/json.Decoder] over the string and stops at the terminal token,
// returning the position at which the decoder was about to read that token.
//
// It is primarily intended for tooling that needs to map a pointer back to a
// region of the original source: reporting line/column for validation or
// parse diagnostics, extracting a sub-document by slicing the raw bytes, or
// highlighting the referenced span in an editor.
//
// # Offset semantics
//
// The meaning of the returned offset depends on whether the terminal token
// addresses an object property or an array element:
//
// - Object property: the offset points to the first byte of the key (its
// opening quote character), not to the associated value. For example,
// pointer "/foo/bar" against {"foo": {"bar": 21}} returns 9, the index of
// the opening quote of "bar".
// - Array element: the offset points to the first byte of the value at that
// index. For example, pointer "/0/1" against [[1,2], [3,4]] returns 4,
// the index of the digit 2.
//
// # Errors
//
// Offset returns an error in any of these cases:
//
// - document is not syntactically valid JSON;
// - the structure of document does not match the pointer (e.g. traversing
// into a scalar, or a token that is neither a valid key nor a valid
// numeric index);
// - a referenced key or index does not exist in document;
// - the pointer's terminal token is the RFC 6901 "-" array token, which
// designates a nonexistent element and therefore has no offset in the
// source. The returned error wraps [ErrDashToken].
//
// All errors wrap [ErrPointer].
func (p *Pointer) Offset(document string) (int64, error) {
dec := json.NewDecoder(strings.NewReader(document))
var offset int64
@@ -137,7 +180,35 @@ func (p *Pointer) Offset(document string) (int64, error) {
return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer)
}
}
return offset, nil
return skipJSONSeparator(document, offset), nil
}
// skipJSONSeparator advances offset past trailing JSON whitespace and at most
// one value separator (comma) in document, so the result points at the first
// byte of the next JSON token.
//
// The streaming decoder's InputOffset sits right after the most recently
// consumed token, which between values is the comma (or whitespace) — not
// the following token. Normalizing here keeps Offset's contract uniform:
// for both object keys and array elements, and regardless of position within
// the parent container, the returned offset always points at the first byte
// of the addressed token.
func skipJSONSeparator(document string, offset int64) int64 {
n := int64(len(document))
for offset < n && isJSONWhitespace(document[offset]) {
offset++
}
if offset < n && document[offset] == ',' {
offset++
}
for offset < n && isJSONWhitespace(document[offset]) {
offset++
}
return offset
}
func isJSONWhitespace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
// "Constructor", parses the given string JSON pointer.
@@ -157,9 +228,9 @@ func (p *Pointer) parse(jsonPointerString string) error {
return nil
}
func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) {
func (p *Pointer) get(node any, nameProvider NameProvider) (any, reflect.Kind, error) {
if nameProvider == nil {
nameProvider = jsonname.DefaultJSONNameProvider
nameProvider = defaultOptions.provider
}
kind := reflect.Invalid
@@ -185,50 +256,130 @@ func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, refle
return node, kind, nil
}
func (p *Pointer) set(node, data any, nameProvider *jsonname.NameProvider) error {
func (p *Pointer) set(node, data any, nameProvider NameProvider) (any, error) {
knd := reflect.ValueOf(node).Kind()
if knd != reflect.Pointer && knd != reflect.Struct && knd != reflect.Map && knd != reflect.Slice && knd != reflect.Array {
return errors.Join(
return node, errors.Join(
fmt.Errorf("unexpected type: %T", node), //nolint:err113 // err wrapping is carried out by errors.Join, not fmt.Errorf.
ErrUnsupportedValueType,
ErrPointer,
)
}
l := len(p.referenceTokens)
// full document when empty
if l == 0 {
return nil
if len(p.referenceTokens) == 0 {
return node, nil
}
if nameProvider == nil {
nameProvider = jsonname.DefaultJSONNameProvider
nameProvider = defaultOptions.provider
}
var decodedToken string
lastIndex := l - 1
if lastIndex > 0 { // skip if we only have one token in pointer
for _, token := range p.referenceTokens[:lastIndex] {
decodedToken = Unescape(token)
next, err := p.resolveNodeForToken(node, decodedToken, nameProvider)
if err != nil {
return err
}
node = next
}
}
// last token
decodedToken = Unescape(p.referenceTokens[lastIndex])
return setSingleImpl(node, data, decodedToken, nameProvider)
return p.setAt(node, p.referenceTokens, data, nameProvider)
}
func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider *jsonname.NameProvider) (next any, err error) {
// setAt recursively walks the token list, setting the data at the terminal
// token and rebinding any new child reference (e.g. a slice header returned
// by an "-" append) into its parent on the way back up.
//
// Returning the (possibly new) node at each level is what makes append work
// at any depth without requiring the caller to pass a pointer to the
// containing slice: the new slice header propagates up and each parent
// rebinds it via the appropriate kind-specific setter.
func (p *Pointer) setAt(node any, tokens []string, data any, nameProvider NameProvider) (any, error) {
decodedToken := Unescape(tokens[0])
if len(tokens) == 1 {
return setSingleImpl(node, data, decodedToken, nameProvider)
}
child, err := p.resolveNodeForToken(node, decodedToken, nameProvider)
if err != nil {
return node, err
}
newChild, err := p.setAt(child, tokens[1:], data, nameProvider)
if err != nil {
return node, err
}
return rebindChild(node, decodedToken, newChild, nameProvider)
}
// rebindChild writes newChild back into node at decodedToken.
//
// For cases where the child was already mutated in place (pointer aliasing,
// addressable slice elements) the rebind is a safe no-op. For cases where
// the child was returned by value (map entries holding a slice, slices
// reached through a non-addressable ancestor), the rebind propagates the
// new value into the parent.
//
// Parents implementing [JSONPointable] are left alone: they took ownership
// of the child via JSONLookup and did not opt into a JSONSet-based rebind
// on intermediate tokens.
func rebindChild(node any, decodedToken string, newChild any, nameProvider NameProvider) (any, error) {
if _, ok := node.(JSONPointable); ok {
return node, nil
}
rValue := reflect.Indirect(reflect.ValueOf(node))
switch rValue.Kind() {
case reflect.Struct:
nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken)
if !ok {
return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer)
}
fld := rValue.FieldByName(nm)
if !fld.CanSet() {
return node, nil
}
assignReflectValue(fld, newChild)
return node, nil
case reflect.Map:
rValue.SetMapIndex(reflect.ValueOf(decodedToken), reflect.ValueOf(newChild))
return node, nil
case reflect.Slice:
if decodedToken == dashToken {
return node, errDashIntermediate()
}
idx, err := strconv.Atoi(decodedToken)
if err != nil {
return node, errors.Join(err, ErrPointer)
}
elem := rValue.Index(idx)
if !elem.CanSet() {
return node, nil
}
assignReflectValue(elem, newChild)
return node, nil
default:
return node, errInvalidReference(decodedToken)
}
}
// assignReflectValue assigns src into dst, unwrapping a pointer when dst
// expects the pointee type. This tolerates the pointer-wrapping performed
// by [typeFromValue] for addressable fields.
func assignReflectValue(dst reflect.Value, src any) {
nv := reflect.ValueOf(src)
if !nv.IsValid() {
return
}
if nv.Type().AssignableTo(dst.Type()) {
dst.Set(nv)
return
}
if nv.Kind() == reflect.Pointer && nv.Elem().Type().AssignableTo(dst.Type()) {
dst.Set(nv.Elem())
}
}
func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider NameProvider) (next any, err error) {
// check for nil during traversal
if isNil(node) {
return nil, fmt.Errorf("cannot traverse through nil value at %q: %w", decodedToken, ErrPointer)
@@ -272,6 +423,9 @@ func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvide
return typeFromValue(mv), nil
case reflect.Slice:
if decodedToken == dashToken {
return nil, errDashIntermediate()
}
tokenIndex, err := strconv.Atoi(decodedToken)
if err != nil {
return nil, errors.Join(err, ErrPointer)
@@ -312,16 +466,23 @@ func typeFromValue(v reflect.Value) any {
}
// GetForToken gets a value for a json pointer token 1 level deep.
func GetForToken(document any, decodedToken string) (any, reflect.Kind, error) {
return getSingleImpl(document, decodedToken, jsonname.DefaultJSONNameProvider)
func GetForToken(document any, decodedToken string, opts ...Option) (any, reflect.Kind, error) {
o := optionsWithDefaults(opts)
return getSingleImpl(document, decodedToken, o.provider)
}
// SetForToken sets a value for a json pointer token 1 level deep.
func SetForToken(document any, decodedToken string, value any) (any, error) {
return document, setSingleImpl(document, value, decodedToken, jsonname.DefaultJSONNameProvider)
//
// See [Pointer.Set] for the mutation contract, in particular the handling of
// the RFC 6901 "-" token on slices.
func SetForToken(document any, decodedToken string, value any, opts ...Option) (any, error) {
o := optionsWithDefaults(opts)
return setSingleImpl(document, value, decodedToken, o.provider)
}
func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) {
func getSingleImpl(node any, decodedToken string, nameProvider NameProvider) (any, reflect.Kind, error) {
rValue := reflect.Indirect(reflect.ValueOf(node))
kind := rValue.Kind()
if isNil(node) {
@@ -361,6 +522,9 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro
return nil, kind, errNoKey(decodedToken)
case reflect.Slice:
if decodedToken == dashToken {
return nil, kind, errDashOnGet()
}
tokenIndex, err := strconv.Atoi(decodedToken)
if err != nil {
return nil, kind, errors.Join(err, ErrPointer)
@@ -378,14 +542,14 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro
}
}
func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.NameProvider) error {
func setSingleImpl(node, data any, decodedToken string, nameProvider NameProvider) (any, error) {
// check for nil to prevent panic when calling rValue.Type()
if isNil(node) {
return fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer)
return node, fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer)
}
if ns, ok := node.(JSONSetable); ok {
return ns.JSONSet(decodedToken, data)
return node, ns.JSONSet(decodedToken, data)
}
rValue := reflect.Indirect(reflect.ValueOf(node))
@@ -394,12 +558,12 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N
case reflect.Struct:
nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken)
if !ok {
return fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer)
return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer)
}
fld := rValue.FieldByName(nm)
if !fld.CanSet() {
return fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer)
return node, fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer)
}
value := reflect.ValueOf(data)
@@ -407,33 +571,51 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N
assignedType := fld.Type()
if !valueType.AssignableTo(assignedType) {
return fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer)
return node, fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer)
}
fld.Set(value)
return nil
return node, nil
case reflect.Map:
kv := reflect.ValueOf(decodedToken)
rValue.SetMapIndex(kv, reflect.ValueOf(data))
return nil
return node, nil
case reflect.Slice:
if decodedToken == dashToken {
// RFC 6901 §4 / RFC 6902 append semantics: terminal "-" appends
// the value to the slice. We rebind in place when the slice is
// reachable via an addressable ancestor; otherwise we return the
// new slice header for the parent (or the public Set) to rebind.
value := reflect.ValueOf(data)
elemType := rValue.Type().Elem()
if !value.Type().AssignableTo(elemType) {
return node, fmt.Errorf("can't append value of type %T to slice of %v: %w", data, elemType, ErrPointer)
}
newSlice := reflect.Append(rValue, value)
if rValue.CanSet() {
rValue.Set(newSlice)
return node, nil
}
return newSlice.Interface(), nil
}
tokenIndex, err := strconv.Atoi(decodedToken)
if err != nil {
return errors.Join(err, ErrPointer)
return node, errors.Join(err, ErrPointer)
}
sLength := rValue.Len()
if tokenIndex < 0 || tokenIndex >= sLength {
return errOutOfBounds(sLength, tokenIndex)
return node, errOutOfBounds(sLength, tokenIndex)
}
elem := rValue.Index(tokenIndex)
if !elem.CanSet() {
return fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer)
return node, fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer)
}
value := reflect.ValueOf(data)
@@ -441,15 +623,15 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N
assignedType := elem.Type()
if !valueType.AssignableTo(assignedType) {
return fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer)
return node, fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer)
}
elem.Set(value)
return nil
return node, nil
default:
return errInvalidReference(decodedToken)
return node, errInvalidReference(decodedToken)
}
}
@@ -460,24 +642,27 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) {
if err != nil {
return 0, err
}
switch tk := tk.(type) {
case json.Delim:
switch tk {
case '{':
if err = drainSingle(dec); err != nil {
return 0, err
}
case '[':
key, ok := tk.(string)
if !ok {
return 0, fmt.Errorf("invalid key token %#v: %w", tk, ErrPointer)
}
if key == decodedToken {
return offset, nil
}
// Consume the associated value. Scalars are fully read by a single
// Token() call; composite values must be drained.
tk, err = dec.Token()
if err != nil {
return 0, err
}
if delim, isDelim := tk.(json.Delim); isDelim {
switch delim {
case '{', '[':
if err = drainSingle(dec); err != nil {
return 0, err
}
}
case string:
if tk == decodedToken {
return offset, nil
}
default:
return 0, fmt.Errorf("invalid token %#v: %w", tk, ErrPointer)
}
}
@@ -485,6 +670,9 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) {
}
func offsetSingleArray(dec *json.Decoder, decodedToken string) (int64, error) {
if decodedToken == dashToken {
return 0, errDashOnOffset()
}
idx, err := strconv.Atoi(decodedToken)
if err != nil {
return 0, fmt.Errorf("token reference %q is not a number: %w: %w", decodedToken, err, ErrPointer)
-1
View File
@@ -3,4 +3,3 @@
.idea
.env
.mcp.json
.claude/
+11 -11
View File
@@ -4,18 +4,18 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 9 | 73 |
| 9 | 79 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 36 | https://github.com/go-openapi/jsonreference/commits?author=fredbi |
| @casualjim | 25 | https://github.com/go-openapi/jsonreference/commits?author=casualjim |
| @youyuanwu | 5 | https://github.com/go-openapi/jsonreference/commits?author=youyuanwu |
| @olivierlemasle | 2 | https://github.com/go-openapi/jsonreference/commits?author=olivierlemasle |
| @apelisse | 1 | https://github.com/go-openapi/jsonreference/commits?author=apelisse |
| @gbjk | 1 | https://github.com/go-openapi/jsonreference/commits?author=gbjk |
| @honza | 1 | https://github.com/go-openapi/jsonreference/commits?author=honza |
| @Neo2308 | 1 | https://github.com/go-openapi/jsonreference/commits?author=Neo2308 |
| @erraggy | 1 | https://github.com/go-openapi/jsonreference/commits?author=erraggy |
| @fredbi | 42 | <https://github.com/go-openapi/jsonreference/commits?author=fredbi> |
| @casualjim | 25 | <https://github.com/go-openapi/jsonreference/commits?author=casualjim> |
| @youyuanwu | 5 | <https://github.com/go-openapi/jsonreference/commits?author=youyuanwu> |
| @olivierlemasle | 2 | <https://github.com/go-openapi/jsonreference/commits?author=olivierlemasle> |
| @apelisse | 1 | <https://github.com/go-openapi/jsonreference/commits?author=apelisse> |
| @gbjk | 1 | <https://github.com/go-openapi/jsonreference/commits?author=gbjk> |
| @honza | 1 | <https://github.com/go-openapi/jsonreference/commits?author=honza> |
| @Neo2308 | 1 | <https://github.com/go-openapi/jsonreference/commits?author=Neo2308> |
| @erraggy | 1 | <https://github.com/go-openapi/jsonreference/commits?author=erraggy> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+10 -12
View File
@@ -14,15 +14,9 @@
An implementation of JSON Reference for golang.
<!--
## Announcements
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
-->
## Status
@@ -74,9 +68,9 @@ on top of which it has been built.
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -115,7 +109,7 @@ Maintainers can cut a new release by either:
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
@@ -125,3 +119,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/jsonreference/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonreference
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonreference/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+9
View File
@@ -0,0 +1,9 @@
codecov:
notify:
after_n_builds: 2
coverage:
status:
patch:
default:
target: 80%
+2
View File
@@ -3,3 +3,5 @@
.idea
.env
.mcp.json
go.work.sum
.worktrees/
+20 -10
View File
@@ -2,13 +2,9 @@ version: "2"
linters:
default: all
disable:
- cyclop
- depguard
- err113 # disabled temporarily: there are just too many issues to address
- errchkjson
- errorlint
- exhaustruct
- forcetypeassert
- funlen
- gochecknoglobals
- gochecknoinits
@@ -16,12 +12,12 @@ linters:
- godot
- godox
- gomoddirectives # moved to mono-repo, multi-modules, so replace directives are needed
- gomodguard
- gomodguard_v2
- gosmopolitan
- inamedparam
- ireturn
- lll
- ireturn # this repo adopted a pattern where there are quite many returned interfaces. To be challenged with v2
- musttag
- nestif
- nilerr # nilerr crashes on this repo
- nlreturn
- noinlineerr
@@ -31,7 +27,6 @@ linters:
- testpackage
- thelper
- tparallel
- unparam
- varnamelen
- whitespace
- wrapcheck
@@ -43,8 +38,17 @@ linters:
goconst:
min-len: 2
min-occurrences: 3
cyclop:
max-complexity: 25
gocyclo:
min-complexity: 45
min-complexity: 25
gocognit:
min-complexity: 35
exhaustive:
default-signifies-exhaustive: true
default-case-required: true
lll:
line-length: 180
exclusions:
generated: lax
presets:
@@ -53,6 +57,7 @@ linters:
- legacy
- std-error-handling
paths:
- .worktrees
- third_party$
- builtin$
- examples$
@@ -60,12 +65,17 @@ formatters:
enable:
- gofmt
- goimports
settings:
# local prefixes regroup imports from these packages
goimports:
local-prefixes:
- github.com/go-openapi
exclusions:
generated: lax
paths:
- .worktrees
- third_party$
- builtin$
- examples$
issues:
# Maximum issues count per one linter.
# Set to 0 to disable.
+83
View File
@@ -0,0 +1,83 @@
# Contributors
- Repository: ['go-openapi/runtime']
| Total Contributors | Total Contributions |
| --- | --- |
| 71 | 565 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 268 | <https://github.com/go-openapi/runtime/commits?author=casualjim> |
| @fredbi | 140 | <https://github.com/go-openapi/runtime/commits?author=fredbi> |
| @youyuanwu | 19 | <https://github.com/go-openapi/runtime/commits?author=youyuanwu> |
| @josephwoodward | 13 | <https://github.com/go-openapi/runtime/commits?author=josephwoodward> |
| @kenjones-cisco | 12 | <https://github.com/go-openapi/runtime/commits?author=kenjones-cisco> |
| @GlenDC | 7 | <https://github.com/go-openapi/runtime/commits?author=GlenDC> |
| @moenning | 6 | <https://github.com/go-openapi/runtime/commits?author=moenning> |
| @mstoykov | 6 | <https://github.com/go-openapi/runtime/commits?author=mstoykov> |
| @elakito | 6 | <https://github.com/go-openapi/runtime/commits?author=elakito> |
| @ifraixedes | 5 | <https://github.com/go-openapi/runtime/commits?author=ifraixedes> |
| @zeitlinger | 4 | <https://github.com/go-openapi/runtime/commits?author=zeitlinger> |
| @Copilot | 3 | <https://github.com/go-openapi/runtime/commits?author=Copilot> |
| @jkawamoto | 3 | <https://github.com/go-openapi/runtime/commits?author=jkawamoto> |
| @stoyanr | 3 | <https://github.com/go-openapi/runtime/commits?author=stoyanr> |
| @keramix | 2 | <https://github.com/go-openapi/runtime/commits?author=keramix> |
| @Equanox | 2 | <https://github.com/go-openapi/runtime/commits?author=Equanox> |
| @ederavilaprado | 2 | <https://github.com/go-openapi/runtime/commits?author=ederavilaprado> |
| @nan0tube | 2 | <https://github.com/go-openapi/runtime/commits?author=nan0tube> |
| @thomdixon | 2 | <https://github.com/go-openapi/runtime/commits?author=thomdixon> |
| @deborggraever | 2 | <https://github.com/go-openapi/runtime/commits?author=deborggraever> |
| @MakarandNsd | 2 | <https://github.com/go-openapi/runtime/commits?author=MakarandNsd> |
| @Vadskye | 2 | <https://github.com/go-openapi/runtime/commits?author=Vadskye> |
| @jsilland | 2 | <https://github.com/go-openapi/runtime/commits?author=jsilland> |
| @Kunde21 | 2 | <https://github.com/go-openapi/runtime/commits?author=Kunde21> |
| @bcomnes | 2 | <https://github.com/go-openapi/runtime/commits?author=bcomnes> |
| @galaxie | 2 | <https://github.com/go-openapi/runtime/commits?author=galaxie> |
| @anfernee | 2 | <https://github.com/go-openapi/runtime/commits?author=anfernee> |
| @wahabmk | 1 | <https://github.com/go-openapi/runtime/commits?author=wahabmk> |
| @vearutop | 1 | <https://github.com/go-openapi/runtime/commits?author=vearutop> |
| @tschaub | 1 | <https://github.com/go-openapi/runtime/commits?author=tschaub> |
| @pytlesk4 | 1 | <https://github.com/go-openapi/runtime/commits?author=pytlesk4> |
| @tgraf | 1 | <https://github.com/go-openapi/runtime/commits?author=tgraf> |
| @seanprince | 1 | <https://github.com/go-openapi/runtime/commits?author=seanprince> |
| @rodriguise | 1 | <https://github.com/go-openapi/runtime/commits?author=rodriguise> |
| @petrkotas | 1 | <https://github.com/go-openapi/runtime/commits?author=petrkotas> |
| @maxatome | 1 | <https://github.com/go-openapi/runtime/commits?author=maxatome> |
| @maxkarelov | 1 | <https://github.com/go-openapi/runtime/commits?author=maxkarelov> |
| @tooolbox | 1 | <https://github.com/go-openapi/runtime/commits?author=tooolbox> |
| @akutz | 1 | <https://github.com/go-openapi/runtime/commits?author=akutz> |
| @yabberyabber | 1 | <https://github.com/go-openapi/runtime/commits?author=yabberyabber> |
| @elv-gilles | 1 | <https://github.com/go-openapi/runtime/commits?author=elv-gilles> |
| @gregmarr | 1 | <https://github.com/go-openapi/runtime/commits?author=gregmarr> |
| @jwalter1-quest | 1 | <https://github.com/go-openapi/runtime/commits?author=jwalter1-quest> |
| @s4s7 | 1 | <https://github.com/go-openapi/runtime/commits?author=s4s7> |
| @stingshen | 1 | <https://github.com/go-openapi/runtime/commits?author=stingshen> |
| @tamalsaha | 1 | <https://github.com/go-openapi/runtime/commits?author=tamalsaha> |
| @tte | 1 | <https://github.com/go-openapi/runtime/commits?author=tte> |
| @martian4202 | 1 | <https://github.com/go-openapi/runtime/commits?author=martian4202> |
| @yan-zhuang | 1 | <https://github.com/go-openapi/runtime/commits?author=yan-zhuang> |
| @aleksandr-vin | 1 | <https://github.com/go-openapi/runtime/commits?author=aleksandr-vin> |
| @azylman | 1 | <https://github.com/go-openapi/runtime/commits?author=azylman> |
| @anasmuhmd | 1 | <https://github.com/go-openapi/runtime/commits?author=anasmuhmd> |
| @ArFe | 1 | <https://github.com/go-openapi/runtime/commits?author=ArFe> |
| @CodeLingoBot | 1 | <https://github.com/go-openapi/runtime/commits?author=CodeLingoBot> |
| @dlmiddlecote | 1 | <https://github.com/go-openapi/runtime/commits?author=dlmiddlecote> |
| @danny-cheung | 1 | <https://github.com/go-openapi/runtime/commits?author=danny-cheung> |
| @calavera | 1 | <https://github.com/go-openapi/runtime/commits?author=calavera> |
| @EdwardBetts | 1 | <https://github.com/go-openapi/runtime/commits?author=EdwardBetts> |
| @etsangsplk | 1 | <https://github.com/go-openapi/runtime/commits?author=etsangsplk> |
| @ericzsplk | 1 | <https://github.com/go-openapi/runtime/commits?author=ericzsplk> |
| @faguirre1 | 1 | <https://github.com/go-openapi/runtime/commits?author=faguirre1> |
| @florindragos | 1 | <https://github.com/go-openapi/runtime/commits?author=florindragos> |
| @gbjk | 1 | <https://github.com/go-openapi/runtime/commits?author=gbjk> |
| @taisho6339 | 1 | <https://github.com/go-openapi/runtime/commits?author=taisho6339> |
| @jbowes | 1 | <https://github.com/go-openapi/runtime/commits?author=jbowes> |
| @JoakimSoderberg | 1 | <https://github.com/go-openapi/runtime/commits?author=JoakimSoderberg> |
| @robbert229 | 1 | <https://github.com/go-openapi/runtime/commits?author=robbert229> |
| @jonathaningram | 1 | <https://github.com/go-openapi/runtime/commits?author=jonathaningram> |
| @KuaaMU | 1 | <https://github.com/go-openapi/runtime/commits?author=KuaaMU> |
| @germanhs | 1 | <https://github.com/go-openapi/runtime/commits?author=germanhs> |
| @pracucci | 1 | <https://github.com/go-openapi/runtime/commits?author=pracucci> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+56 -20
View File
@@ -8,8 +8,7 @@
[![Release][release-badge]][release-url] [![Go Report Card][gocard-badge]][gocard-url] [![CodeFactor Grade][codefactor-badge]][codefactor-url] [![License][license-badge]][license-url]
<!-- Badges: documentation & support -->
<!-- Badges: others & stats -->
[![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge]
[![Doc][doc-badge]][doc-url] [![GoDoc][godoc-badge]][godoc-url] [![Discord Channel][discord-badge]][discord-url] [![go version][goversion-badge]][goversion-url] ![Top language][top-badge] ![Commits since latest release][commits-badge]
---
A runtime for go OpenAPI toolkit.
@@ -18,13 +17,44 @@ The runtime component for use in code generation or as untyped usage.
## Announcements
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
[**Complete documentation as github pages**][doc-url]
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
**Changes to the API surface in `v0.30.0`**:
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
* utility package `header` has now moved to `github.com/go-openapi/runtime/server-middleware/negotiate/header`
> A shim is provided to support existing programs, with a deprecation notice.
**Changes in semantics in `v0.30.0`**:
Function `negotiate.NegotiateContentType` (available as an alias for backward compatibility as `middleware.NegotiateContentType`
now performs a full match considering MIME parameters.
The previous behavior (matching in order of appearance after stripping parameters) may be enabled explicitly with
option `negotiate.WithIgnoreParameters`.
* **2026-05-07** : exposed UI and Spec middleware as a separate, dependency-free module.
> Newly available package: `github.com/go-openapi/runtime/server-middleware/docui` that now holds our
> UI and spec serve middleware.
>
> A shim is available in `github.com/go-openapi/runtime/middleware` to bridge the older UI options to the new ones,
> with a deprecation notice.
>
> Methods that were unduly exported and purely used to manipulate options (e.g. `SwaggerUIOpts.EnsureDefaults`) have been
> removed. New options in `docui` should be used instead.
> Users may reuse this middleware to serve a Redoc, Rapidoc or SwaggerUI documentation without
> importing the complete go-openapi scaffolding.
* **2026-05-05** : exposed content negotiation methods as a separate, dependency-free module
> Users may reuse these utilities to support content-negotiation without extra dependencies.
>
> Newly available module: `github.com/go-openapi/runtime/server-middleware`
>
> Newly available packages: `github.com/go-openapi/runtime/server-middleware/negotiate` and
> `github.com/go-openapi/runtime/server-middleware/mediatype`.
## Status
@@ -40,18 +70,21 @@ go get github.com/go-openapi/runtime
See <https://github.com/go-openapi/runtime/releases>
For pre-v0.30.0 releases see [release notes](docs/NOTES.md).
For v0.29.0 release see [release notes](docs/NOTES.md).
From that release onwards, changes are tracked in the github release notes.
**What coming next?**
Moving forward, we want to :
* [ ] continue narrowing down the scope of dependencies:
* yaml support in an independent module
* [x] fix a few known issues with some file upload requests (e.g. #286)
* [] continue narrowing down the scope of dependencies:
* [x] split middleware and other useful utilities as a separate dependency-free module
* yaml support in an independent module (v2)
* introduce more up-to-date support for opentelemetry as a separate module that evolves
independently from the main package (to avoid breaking changes, the existing API
will remain maintained, but evolve at a slower pace than opentelemetry).
* [ ] fix a few known issues with some file upload requests (e.g. #286)
will remain maintained, but evolve at a slower pace than opentelemetry). (v2)
* [] publish proper documentation and examples
## Licensing
@@ -62,11 +95,11 @@ on top of which it has been built.
## Other documentation
* [FAQ](docs/FAQ.md)
* [FAQ](https://go-openapi.github.io/runtime/tutorials/faq/) · [Media-type selection](https://go-openapi.github.io/runtime/tutorials/media-types/) · [Client keep-alive](https://go-openapi.github.io/runtime/tutorials/keep-alive/)
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -95,13 +128,12 @@ Maintainers can cut a new release by either:
[codefactor-badge]: https://img.shields.io/codefactor/grade/github/go-openapi/runtime
[codefactor-url]: https://www.codefactor.io/repository/github/go-openapi/runtime
<!-- Badges: documentation & support -->
[doc-badge]: https://img.shields.io/badge/doc-site-blue?link=https%3A%2F%2Fgo-openapi.github.io%2Fruntime%2F
[doc-url]: https://go-openapi.github.io/runtime
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/runtime
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/runtime
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
@@ -111,3 +143,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/runtime/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/runtime
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/runtime/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+8 -8
View File
@@ -97,7 +97,7 @@ func ByteStreamConsumer(opts ...byteStreamOpt) Consumer {
}
default:
// check for the underlying type to be pointer to []byte or string,
if ptr := reflect.TypeOf(data); ptr.Kind() != reflect.Ptr {
if ptr := reflect.TypeOf(data); ptr.Kind() != reflect.Pointer {
return errors.New("destination must be a pointer")
}
@@ -126,13 +126,13 @@ func ByteStreamConsumer(opts ...byteStreamOpt) Consumer {
//
// Supported input underlying types and interfaces, prioritized in this order:
//
// - [io.WriterTo] (for maximum control)
// - [io.Reader] (performs [io.Copy]). A ReadCloser is closed before exiting.
// - [encoding.BinaryMarshaler]
// - error (writes as a string)
// - []byte
// - string
// - struct, other slices: writes as JSON.
// - [io.WriterTo] (for maximum control)
// - [io.Reader] (performs [io.Copy]). A ReadCloser is closed before exiting.
// - [encoding.BinaryMarshaler]
// - error (writes as a string)
// - []byte
// - string
// - struct, other slices: writes as JSON.
func ByteStreamProducer(opts ...byteStreamOpt) Producer {
var vals byteStreamOpts
for _, opt := range opts {
+520
View File
@@ -0,0 +1,520 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http/httptrace"
"strings"
"sync"
"time"
"github.com/go-openapi/runtime/logger"
)
// traceSession owns the per-request state for [Runtime.Trace].
//
// It tracks the t=0 anchor for the connection phase, accumulates
// per-phase timestamps (for the trailing summary), and emits each
// event to the runtime logger as it fires. One session per
// SubmitContext call.
type traceSession struct {
logger logger.Logger
method string
url string
// tlsCfg points at the *tls.Config of the http.Transport that
// will run the request, when introspectable (i.e. the transport
// is an *http.Transport). Used by the TLS diagnostic mode to
// cross-check user configuration against what the handshake
// actually attempted. Nil when the transport is custom and
// the config cannot be reached.
tlsCfg *tls.Config
mu sync.Mutex
start time.Time
last time.Time // last printed event, for relative-dt rendering
phases phaseTimings
gotConn httptrace.GotConnInfo
tlsDone tlsResult
dnsStartAt time.Time
connectStartAt time.Time
tlsHandshakeStartAt time.Time
wait100StartAt time.Time
gotConnAt time.Time
wroteHeadersAt time.Time
wroteRequestAt time.Time
ttfbAt time.Time
statusCode int
rtError error
}
// phaseTimings holds the per-phase durations for the trailing
// summary line. Zero values mean "phase did not occur" (e.g. no
// DNS lookup on a reused conn, no TLS on http://).
type phaseTimings struct {
dns time.Duration
dial time.Duration
tls time.Duration
ttfb time.Duration // time from GotConn to first response byte
}
// tlsResult captures whatever we learned from TLSHandshakeDone.
// On the happy path err is nil and state is fully populated; on
// failure state may be partial (and is what the TLS diagnostic
// mode in httptrace_tls.go works from).
type tlsResult struct {
state tls.ConnectionState
err error
done bool
}
const tracePrefix = "[trace] "
// staleIdleThreshold is the idle duration above which a reused
// pooled connection earns a HEADS-UP annotation. Per-runtime
// configurability is deferred to v2; 30s matches the issue #336
// territory (typical NAT idle timeouts start in the 60350s
// range, so a 30s reuse is already in "could be stale" zone).
const staleIdleThreshold = 30 * time.Second
// newTraceSession allocates a session and pre-renders the opening
// line (method + url). The session is not yet attached to a
// context — that's the caller's responsibility via session.attach.
//
// tlsCfg may be nil; when non-nil it is used by the TLS diagnostic
// mode to cross-check user-configured constraints (MinVersion,
// CipherSuites, custom RootCAs) against handshake failures.
func newTraceSession(log logger.Logger, method, url string, tlsCfg *tls.Config) *traceSession {
s := &traceSession{
logger: log,
method: method,
url: url,
tlsCfg: tlsCfg,
start: time.Now(),
}
s.last = s.start
s.emitf("%s %s", method, url)
return s
}
// attach installs the session's ClientTrace on ctx and returns the
// derived context. Callers pass the returned context to
// http.Client.Do (typically by setting it on req via
// req.WithContext) so the transport fires the hooks.
func (s *traceSession) attach(ctx context.Context) context.Context {
return httptrace.WithClientTrace(ctx, s.clientTrace())
}
// clientTrace wires every httptrace hook to the corresponding
// session method. Each callback is responsible for its own
// locking; the stdlib does not serialize trace callbacks.
func (s *traceSession) clientTrace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
GetConn: s.onGetConn,
GotConn: s.onGotConn,
PutIdleConn: s.onPutIdleConn,
GotFirstResponseByte: s.onGotFirstResponseByte,
Got100Continue: s.onGot100Continue,
DNSStart: s.onDNSStart,
DNSDone: s.onDNSDone,
ConnectStart: s.onConnectStart,
ConnectDone: s.onConnectDone,
TLSHandshakeStart: s.onTLSHandshakeStart,
TLSHandshakeDone: s.onTLSHandshakeDone,
WroteHeaders: s.onWroteHeaders,
Wait100Continue: s.onWait100Continue,
WroteRequest: s.onWroteRequest,
}
}
// ---------------------------------------------------------------
// Phase callbacks (stdlib httptrace hooks)
// ---------------------------------------------------------------
func (s *traceSession) onGetConn(hostPort string) {
s.emitTf("GetConn(%s)", hostPort)
}
func (s *traceSession) onGotConn(info httptrace.GotConnInfo) {
s.mu.Lock()
s.gotConn = info
s.gotConnAt = time.Now()
s.mu.Unlock()
if info.Reused {
s.emitTf("GotConn(reused=true, idle=%t, idle-time=%s)",
info.WasIdle, info.IdleTime.Round(time.Millisecond))
} else {
s.emitTf("GotConn(reused=false)")
}
if isStaleIdleReuse(info) {
s.emitf("# HEADS-UP: reused idle connection (idle for %s).",
info.IdleTime.Round(time.Second))
s.emitf("# If this request fails with EOF/connection reset, the server")
s.emitf("# or an in-path NAT may have dropped the conn silently.")
}
}
// isStaleIdleReuse reports whether a GotConn info indicates the
// connection came from the idle pool after sitting idle for
// longer than [staleIdleThreshold]. This is the issue #336
// pattern: long-idle pooled conns are the ones most likely to be
// dead by the time the next request tries to use them.
func isStaleIdleReuse(info httptrace.GotConnInfo) bool {
return info.Reused && info.WasIdle && info.IdleTime > staleIdleThreshold
}
func (s *traceSession) onPutIdleConn(err error) {
if err != nil {
s.emitTf("PutIdleConn(err=%v)", err)
return
}
s.emitTf("PutIdleConn")
}
func (s *traceSession) onGotFirstResponseByte() {
s.mu.Lock()
s.ttfbAt = time.Now()
if !s.gotConnAt.IsZero() {
s.phases.ttfb = s.ttfbAt.Sub(s.gotConnAt)
}
s.mu.Unlock()
s.emitTf("GotFirstResponseByte (TTFB)")
}
func (s *traceSession) onGot100Continue() {
s.emitTf("Got100Continue")
}
func (s *traceSession) onDNSStart(info httptrace.DNSStartInfo) {
s.mu.Lock()
s.dnsStartAt = time.Now()
s.mu.Unlock()
s.emitTf("DNSStart(host=%s)", info.Host)
}
func (s *traceSession) onDNSDone(info httptrace.DNSDoneInfo) {
s.mu.Lock()
if !s.dnsStartAt.IsZero() {
s.phases.dns = time.Since(s.dnsStartAt)
}
s.mu.Unlock()
addrs := make([]string, 0, len(info.Addrs))
for _, a := range info.Addrs {
addrs = append(addrs, a.String())
}
if info.Err != nil {
s.emitTf("DNSDone(err=%v, addrs=[%s], coalesced=%t)",
info.Err, strings.Join(addrs, " "), info.Coalesced)
return
}
s.emitTf("DNSDone(addrs=[%s], coalesced=%t)",
strings.Join(addrs, " "), info.Coalesced)
}
func (s *traceSession) onConnectStart(network, addr string) {
s.mu.Lock()
s.connectStartAt = time.Now()
s.mu.Unlock()
s.emitTf("ConnectStart(%s %s)", network, addr)
}
func (s *traceSession) onConnectDone(network, addr string, err error) {
s.mu.Lock()
if !s.connectStartAt.IsZero() {
s.phases.dial = time.Since(s.connectStartAt)
}
s.mu.Unlock()
if err != nil {
s.emitTf("ConnectDone(%s %s, err=%v)", network, addr, err)
return
}
s.emitTf("ConnectDone(%s %s)", network, addr)
}
func (s *traceSession) onTLSHandshakeStart() {
s.mu.Lock()
s.tlsHandshakeStartAt = time.Now()
s.mu.Unlock()
s.emitTf("TLSHandshakeStart")
}
func (s *traceSession) onTLSHandshakeDone(state tls.ConnectionState, err error) {
s.mu.Lock()
if !s.tlsHandshakeStartAt.IsZero() {
s.phases.tls = time.Since(s.tlsHandshakeStartAt)
}
s.tlsDone = tlsResult{state: state, err: err, done: true}
s.mu.Unlock()
if err != nil {
s.emitTf("TLSHandshakeDone(err=%v)", err)
s.emitTLSDiagnostic(state, err)
return
}
s.emitTf("TLSHandshakeDone(tls=%s, cipher=%s, server=%s%s)",
tlsVersionName(state.Version),
tls.CipherSuiteName(state.CipherSuite),
state.ServerName,
certExpiryFragment(state),
)
}
func (s *traceSession) onWroteHeaders() {
s.mu.Lock()
s.wroteHeadersAt = time.Now()
s.mu.Unlock()
s.emitTf("WroteHeaders")
}
func (s *traceSession) onWait100Continue() {
s.mu.Lock()
s.wait100StartAt = time.Now()
s.mu.Unlock()
s.emitTf("Wait100Continue")
}
func (s *traceSession) onWroteRequest(info httptrace.WroteRequestInfo) {
s.mu.Lock()
s.wroteRequestAt = time.Now()
s.mu.Unlock()
if info.Err != nil {
s.emitTf("WroteRequest(err=%v)", info.Err)
return
}
s.emitTf("WroteRequest")
}
// ---------------------------------------------------------------
// Body wrapping
// ---------------------------------------------------------------
// bodySide identifies which direction an instrumented body is on.
type bodySide string
const (
bodySend bodySide = "Sent"
bodyRecv bodySide = "Received"
)
// instrumentedBody wraps an [io.ReadCloser] and emits a
// BodyChunk{Sent,Received} trace event per Read call. Tracks the
// inter-read delay in `dt` so users can see streaming-body
// cadence.
//
// Read granularity: bytes returned by the underlying body, not
// HTTP/1.1 chunked-framing units. For wire-level chunking, use
// [Runtime.Debug] instead.
//
// Concurrency: a single body is read from a single goroutine in
// practice (http.Transport for request bodies, the application
// for response bodies), so no internal locking is needed beyond
// what the underlying ReadCloser provides.
type instrumentedBody struct {
wrapped io.ReadCloser
sess *traceSession
side bodySide
last time.Time
}
func (b *instrumentedBody) Read(p []byte) (int, error) {
n, err := b.wrapped.Read(p)
if n > 0 {
first := b.last.IsZero()
var dt time.Duration
if !first {
dt = time.Since(b.last)
}
b.last = time.Now()
b.sess.onBodyChunk(b.side, n, dt, first)
}
return n, err
}
func (b *instrumentedBody) Close() error {
return b.wrapped.Close()
}
// wrapRequestBody returns an instrumented wrapper around the
// outgoing request body, or the original body if nil (which is
// the common case for GET requests). The wrapper observes
// Transport-side reads, so BodyChunkSent events appear between
// WroteHeaders and WroteRequest in the trace timeline.
func (s *traceSession) wrapRequestBody(body io.ReadCloser) io.ReadCloser {
if body == nil {
return nil
}
return &instrumentedBody{wrapped: body, sess: s, side: bodySend}
}
// wrapResponseBody returns an instrumented wrapper around the
// incoming response body. Stacks cleanly above
// [KeepAliveTransport]'s drain-on-close behavior.
func (s *traceSession) wrapResponseBody(body io.ReadCloser) io.ReadCloser {
if body == nil {
return nil
}
return &instrumentedBody{wrapped: body, sess: s, side: bodyRecv}
}
// onBodyChunk renders a single BodyChunk{Sent,Received} event.
// dt is the duration since the previous Read on the same body and
// is meaningful only when `first` is false. The first chunk has no
// preceding read, so the dt= field is suppressed; every subsequent
// chunk emits dt= unconditionally — even when the measured value
// rounds to zero (common on Windows, where the system clock
// resolution is coarser than a fast loopback read loop).
func (s *traceSession) onBodyChunk(side bodySide, n int, dt time.Duration, first bool) {
if first {
s.emitTf("BodyChunk%s(n=%d)", side, n)
return
}
s.emitTf("BodyChunk%s(n=%d, dt=%s)", side, n, round(dt))
}
// ---------------------------------------------------------------
// Submit-level lifecycle hooks (called from SubmitContext)
// ---------------------------------------------------------------
// onRoundTripError is called by SubmitContext when http.Client.Do
// returns an error. It records the error for the summary line.
func (s *traceSession) onRoundTripError(err error) {
s.mu.Lock()
s.rtError = err
s.mu.Unlock()
s.emitTf("! error: %v", err)
}
// onResponse is called when http.Client.Do returns successfully.
// It records the status code for the summary line.
func (s *traceSession) onResponse(statusCode int) {
s.mu.Lock()
s.statusCode = statusCode
s.mu.Unlock()
}
// finish renders the trailing single-line summary and is called
// by SubmitContext after the response body has been consumed (or
// on error path, after the error was recorded). When a round-trip
// error happened on a stale-idle reused connection, a tail block
// flags the issue #336 pattern explicitly.
func (s *traceSession) finish() {
s.mu.Lock()
defer s.mu.Unlock()
total := time.Since(s.start)
var b strings.Builder
fmt.Fprintf(&b, "Summary: %s — ", s.method)
if s.rtError != nil {
fmt.Fprintf(&b, "FAILED (%v)", s.rtError)
} else {
fmt.Fprintf(&b, "%d", s.statusCode)
}
if s.phases.dns > 0 {
fmt.Fprintf(&b, ", dns=%s", round(s.phases.dns))
}
if s.phases.dial > 0 {
fmt.Fprintf(&b, ", dial=%s", round(s.phases.dial))
}
if s.phases.tls > 0 {
fmt.Fprintf(&b, ", tls=%s", round(s.phases.tls))
}
if s.phases.ttfb > 0 {
fmt.Fprintf(&b, ", ttfb=%s", round(s.phases.ttfb))
}
fmt.Fprintf(&b, ", total=%s", round(total))
s.emitRaw(b.String())
// issue #336 tail annotation: a round-trip failure on a
// stale-idle reused conn is the canonical pattern.
if s.rtError != nil && isStaleIdleReuse(s.gotConn) {
s.emitf("# FAILED on a reused idle conn (%s idle).",
s.gotConn.IdleTime.Round(time.Second))
s.emitf("# Silently closed the conn while it sat in the idle pool.")
s.emitf("# Consider lowering http.Transport.IdleConnTimeout to evict")
s.emitf("# pooled conns before the NAT/server side does.")
}
}
// ---------------------------------------------------------------
// Emission helpers
// ---------------------------------------------------------------
// emitf prints a plain event line (no t= timestamp). Used for the
// opening line and the summary.
func (s *traceSession) emitf(format string, args ...any) {
s.logger.Debugf(tracePrefix+format, args...)
}
// emitRaw is like emitf but takes an already-rendered string. Used
// by finish() which builds its line via strings.Builder.
func (s *traceSession) emitRaw(line string) {
s.logger.Debugf("%s", tracePrefix+line)
}
// emitTf prints a phase event with a cumulative t=... offset from
// the session start.
func (s *traceSession) emitTf(format string, args ...any) {
t := round(time.Since(s.start))
msg := fmt.Sprintf(format, args...)
s.logger.Debugf(tracePrefix+"%s (t=%s)", msg, t)
}
// traceRoundUnit is the rounding granularity for >=1ms durations
// rendered in trace output. 100µs keeps lines readable while
// preserving enough resolution to spot millisecond-scale phase
// differences.
const traceRoundUnit = 100 * time.Microsecond
// round trims durations for human-readable trace output.
// Sub-millisecond durations round to 1µs (preserves visibility on
// fast loopback servers); >=1ms durations round to [traceRoundUnit].
func round(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if d < time.Millisecond {
return d.Round(time.Microsecond)
}
return d.Round(traceRoundUnit)
}
// ---------------------------------------------------------------
// TLS rendering helpers
// ---------------------------------------------------------------
func tlsVersionName(v uint16) string {
switch v {
case tls.VersionTLS10:
return "1.0"
case tls.VersionTLS11:
return "1.1"
case tls.VersionTLS12:
return "1.2"
case tls.VersionTLS13:
return "1.3"
default:
return fmt.Sprintf("0x%04x", v)
}
}
// certExpiryFragment renders ", expires=YYYY-MM-DD" for the leaf
// cert when available, or an empty string otherwise.
func certExpiryFragment(state tls.ConnectionState) string {
if len(state.PeerCertificates) == 0 {
return ""
}
return ", expires=" + state.PeerCertificates[0].NotAfter.UTC().Format("2006-01-02")
}
+353
View File
@@ -0,0 +1,353 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package client
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// TLS alert codes used by the diagnostic to classify handshake
// failures. The crypto/tls package does not export named constants
// for individual alerts, so we declare the ones we care about.
// Values are from RFC 8446 §6 (the TLS 1.3 alert protocol; the
// numbering is shared with earlier TLS versions for these alerts).
//
// The `err`-prefixed names satisfy the errname linter — tls.AlertError
// implements error, so these are sentinel errors.
const (
errTLSAlertHandshakeFailure tls.AlertError = 40
errTLSAlertProtocolVersion tls.AlertError = 70
)
// introspectTLSConfig returns the *tls.Config of the http.Transport
// that will run a request, when reachable, or nil otherwise.
//
// Reachable means the client's Transport is an *http.Transport
// (the default and most common case). Custom transports — wrappers
// around the default, or entirely user-provided — break introspection;
// the TLS diagnostic falls back to "configured: not introspectable"
// in that case.
//
// A nil client (zero value) or nil Transport falls through to
// [http.DefaultTransport], whose TLSClientConfig is also nil; the
// function returns nil and the diagnostic reports defaults.
func introspectTLSConfig(client *http.Client) *tls.Config {
if client == nil {
return nil
}
transport := client.Transport
if transport == nil {
transport = http.DefaultTransport
}
t, ok := transport.(*http.Transport)
if !ok {
return nil
}
return t.TLSClientConfig
}
// emitTLSDiagnostic renders the failure-mode TLS diagnostic block.
// Called from [traceSession.onTLSHandshakeDone] when err != nil.
//
// The block covers three axes (per the plan):
//
// 1. Protocol-version negotiation — detected from
// [errTLSAlertProtocolVersion] or a "protocol version" substring.
// 2. Cipher-suite negotiation — detected from
// [errTLSAlertHandshakeFailure] when the user pinned CipherSuites.
// 3. Certificate-chain validity — detected from
// [x509.CertificateInvalidError], [x509.UnknownAuthorityError]
// or [x509.HostnameError].
//
// When none of the specific axes match, a generic fallback emits
// the raw error and whatever inspectable config the session holds.
func (s *traceSession) emitTLSDiagnostic(state tls.ConnectionState, err error) {
s.emitf("# TLS DIAGNOSTIC")
// tlsAxisGeneric is handled by the default branch.
switch axis := classifyTLSError(err); axis {
case tlsAxisProtocolVersion:
s.diagnoseProtocolVersion(state, err)
case tlsAxisCipher:
s.diagnoseCipher(err)
case tlsAxisCertChain:
s.diagnoseCertChain(err)
default:
s.diagnoseTLSGeneric(err)
}
}
// tlsAxis is the diagnostic dimension a TLS handshake error maps
// to. Axes are mutually exclusive at classification time.
type tlsAxis int
const (
tlsAxisGeneric tlsAxis = iota
tlsAxisProtocolVersion
tlsAxisCipher
tlsAxisCertChain
)
// classifyTLSError maps a TLS handshake error to one of the
// diagnostic axes. The ordering matters: cert-chain errors win
// over the generic handshake_failure alert because the alert is
// what the server sends back, but the local error type carries
// the more specific reason.
func classifyTLSError(err error) tlsAxis {
if err == nil {
return tlsAxisGeneric
}
// Cert-chain errors are the most specific local diagnostic
// and should be reported even if a generic alert is also
// present in the chain.
var certInvalid x509.CertificateInvalidError
if errors.As(err, &certInvalid) {
return tlsAxisCertChain
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return tlsAxisCertChain
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return tlsAxisCertChain
}
// TLS alert classification.
var alert tls.AlertError
if errors.As(err, &alert) {
switch alert {
case errTLSAlertProtocolVersion:
return tlsAxisProtocolVersion
case errTLSAlertHandshakeFailure:
return tlsAxisCipher
}
}
// Fall back on substring detection for protocol-version
// failures that arrive via the local error path rather than
// a server-side alert (e.g. when the client refuses the
// server's offered version).
msg := err.Error()
if strings.Contains(msg, "protocol version") || strings.Contains(msg, "unsupported protocol") {
return tlsAxisProtocolVersion
}
return tlsAxisGeneric
}
// ---------------------------------------------------------------
// Axis renderers
// ---------------------------------------------------------------
func (s *traceSession) diagnoseProtocolVersion(state tls.ConnectionState, err error) {
s.emitf("# axis: protocol-version")
s.emitf("# error: %v", err)
configuredMin, configuredMax := configuredVersionRange(s.tlsCfg)
s.emitf("# client offered: TLS %s — TLS %s",
tlsVersionName(configuredMin), tlsVersionName(configuredMax))
if state.Version != 0 {
s.emitf("# negotiated up to: TLS %s", tlsVersionName(state.Version))
}
s.emitf("# suggested: widen TLSClientOptions.MinVersion/MaxVersion,")
s.emitf("# or pin to a version the server speaks.")
}
func (s *traceSession) diagnoseCipher(err error) {
s.emitf("# axis: cipher-suite")
s.emitf("# error: %v", err)
if s.tlsCfg != nil && len(s.tlsCfg.CipherSuites) > 0 {
s.emitf("# client configured: [%s]",
strings.Join(cipherSuiteNames(s.tlsCfg.CipherSuites), ", "))
s.emitf("# server set: not exposed by Go stdlib")
s.emitf("# (capture with: openssl s_client -cipher ALL)")
s.emitf("# suggested: drop the explicit CipherSuites restriction,")
s.emitf("# or align it with the server's policy.")
return
}
// No client-side restriction. The handshake_failure alert
// is generic; without more info we can only surface the
// fact and suggest investigation.
s.emitf("# client configured: defaults (no CipherSuites restriction)")
s.emitf("# note: alert 40 is generic; the server may have rejected")
s.emitf("# the handshake for a non-cipher reason. Try")
s.emitf("# openssl s_client to capture details.")
}
func (s *traceSession) diagnoseCertChain(err error) {
s.emitf("# axis: cert-chain")
var certInvalid x509.CertificateInvalidError
if errors.As(err, &certInvalid) {
s.diagnoseCertInvalid(certInvalid)
return
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
s.diagnoseUnknownAuthority(unknownAuth)
return
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
s.diagnoseHostnameMismatch(hostnameErr)
return
}
// Defensive: should not happen — classifyTLSError already
// matched one of the three.
s.emitf("# error: %v", err)
}
func (s *traceSession) diagnoseCertInvalid(certInvalid x509.CertificateInvalidError) {
cert := certInvalid.Cert
s.emitf("# reason: %s", certInvalidReasonName(certInvalid.Reason))
switch certInvalid.Reason {
case x509.Expired:
s.emitf("# leaf: subject=%s", cert.Subject)
s.emitf("# NotBefore=%s", cert.NotBefore.UTC().Format(time.RFC3339))
s.emitf("# NotAfter=%s", cert.NotAfter.UTC().Format(time.RFC3339))
s.emitf("# now=%s", time.Now().UTC().Format(time.RFC3339))
delta := time.Since(cert.NotAfter).Round(time.Hour)
s.emitf("# expired %s ago", delta)
s.emitf("# suggested: renew the server cert.")
case x509.NameMismatch, x509.CANotAuthorizedForThisName:
s.emitf("# leaf: subject=%s", cert.Subject)
s.emitf("# DNS SANs=%v", cert.DNSNames)
s.emitf("# suggested: set TLSClientOptions.ServerName to match")
s.emitf("# one of the cert SANs, or fix the cert.")
default:
// Less-common reasons render via the default branch (issuer + NotAfter dump).
s.emitf("# leaf: subject=%s, issuer=%s", cert.Subject, cert.Issuer)
s.emitf("# NotBefore=%s", cert.NotBefore.UTC().Format(time.RFC3339))
s.emitf("# NotAfter=%s", cert.NotAfter.UTC().Format(time.RFC3339))
s.emitf("# error: %v", certInvalid)
}
}
func (s *traceSession) diagnoseUnknownAuthority(unknownAuth x509.UnknownAuthorityError) {
s.emitf("# reason: chain root not in trust store (unknown-CA)")
if cert := unknownAuth.Cert; cert != nil {
s.emitf("# offending: subject=%s", cert.Subject)
s.emitf("# issuer=%s", cert.Issuer)
s.emitf("# NotAfter=%s", cert.NotAfter.UTC().Format(time.RFC3339))
}
trust := "SystemCertPool"
if s.tlsCfg != nil && s.tlsCfg.RootCAs != nil {
trust = "TLSClientOptions.CA (custom RootCAs)"
}
s.emitf("# trust store in use: %s", trust)
s.emitf("# suggested: set TLSClientOptions.CA to a bundle that")
s.emitf("# includes the issuing CA, or add it to the")
s.emitf("# OS trust store.")
}
func (s *traceSession) diagnoseHostnameMismatch(hostnameErr x509.HostnameError) {
s.emitf("# reason: hostname mismatch")
s.emitf("# dialed: %s", hostnameErr.Host)
if cert := hostnameErr.Certificate; cert != nil {
s.emitf("# leaf: subject=%s", cert.Subject)
s.emitf("# DNS SANs=%v", cert.DNSNames)
s.emitf("# IP SANs=%v", cert.IPAddresses)
}
if s.tlsCfg != nil && s.tlsCfg.ServerName != "" {
s.emitf("# TLSClientOptions.ServerName=%q", s.tlsCfg.ServerName)
}
s.emitf("# suggested: dial the hostname listed in the cert SANs,")
s.emitf("# or set TLSClientOptions.ServerName to match.")
}
func (s *traceSession) diagnoseTLSGeneric(err error) {
s.emitf("# axis: unclassified")
s.emitf("# error: %v", err)
if s.tlsCfg != nil {
minV, maxV := configuredVersionRange(s.tlsCfg)
s.emitf("# configured: MinVersion=TLS %s, MaxVersion=TLS %s",
tlsVersionName(minV), tlsVersionName(maxV))
if s.tlsCfg.InsecureSkipVerify {
s.emitf("# note: TLSClientOptions.InsecureSkipVerify=true — yet")
s.emitf("# a TLS error still surfaced. Something deeper than")
s.emitf("# certificate verification is failing.")
}
}
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
// configuredVersionRange returns the effective (Min, Max) TLS
// version range a client config negotiates. Zero values in the
// stdlib config mean "use Go default", which is TLS 1.2 .. 1.3 in
// modern Go. We materialize those defaults for display.
func configuredVersionRange(cfg *tls.Config) (uint16, uint16) {
const (
defaultMin = tls.VersionTLS12
defaultMax = tls.VersionTLS13
)
if cfg == nil {
return defaultMin, defaultMax
}
minV := cfg.MinVersion
if minV == 0 {
minV = defaultMin
}
maxV := cfg.MaxVersion
if maxV == 0 {
maxV = defaultMax
}
return minV, maxV
}
func cipherSuiteNames(ids []uint16) []string {
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, tls.CipherSuiteName(id))
}
return out
}
// certInvalidReasonName renders an x509.InvalidReason as a short
// human-readable label. The stdlib does not expose a String()
// method for these, so we keep a small table.
//
// Anything outside the listed cases falls through to the numeric default.
func certInvalidReasonName(r x509.InvalidReason) string {
switch r {
case x509.NotAuthorizedToSign:
return "not-authorized-to-sign"
case x509.Expired:
return "expired"
case x509.CANotAuthorizedForThisName:
return "ca-not-authorized-for-this-name"
case x509.TooManyIntermediates:
return "too-many-intermediates"
case x509.IncompatibleUsage:
return "incompatible-usage"
case x509.NameMismatch:
return "name-mismatch"
case x509.NameConstraintsWithoutSANs:
return "name-constraints-without-sans"
case x509.TooManyConstraints:
return "too-many-constraints"
case x509.CANotAuthorizedForExtKeyUsage:
return "ca-not-authorized-for-ext-key-usage"
default:
return fmt.Sprintf("invalid-reason-%d", r)
}
}
+945
View File
@@ -0,0 +1,945 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package request
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
)
var _ runtime.ClientRequest = new(Request) // ensure compliance to the interface
// Request represents a swagger client request.
// It binds parameters to a HTTP request.
//
// The main purpose of this struct is to hide the machinery of adding OpenAPI v2 parameters to a transport request.
//
// A generated client only implements what is necessary to turn a parameter into a valid value for these methods.
//
// There is no parameter validation here, it is assumed to be used after a spec has been validated.
//
// # Request binding
//
// The binding of parameters is carried out by method [Request.BuildHTTPContext].
//
// It analyzes parameters, which may come in different flavors:
//
// - a file or multipart form containing a file
// - a body which is a [io.Reader]
// - a buffered body (regular schema body, including urlencoded form)
//
// In all cases, we may also have query or path parameters encoded in the URL, or header parameters.
//
// The result is a [http.Request], with the following properties:
//
// - file, multipart form or [io.Reader] body: a streaming request with an attached go routine that consumes the [io.Reader].
// - buffered body: a simple request
//
// The caller passes the parent [context.Context] to [Request.BuildHTTPContext] and receives back a cancel
// function to release the resources held by the derived request context once the response is consumed.
//
// # Authentication
//
// Authentication is built in the request by using a [runtime.ClientAuthInfoWriter].
// This helper may need to inspect the body of the request before sending authentication info.
// To cover that case, streaming bodies use a copy of the body [io.Reader] for the [runtime.ClientAuthInfoWriter]
// to consume if it wants to.
//
// # Content negotiation
//
// The [Request] detects `multipart/form-data` to switch to streamed request.
//
// `application/x-www-form-urlencoded` is also honored, even for file parameters, which are not streamed in this case.
// File parameters default behavior is `multipart/form-data`.
//
// The natural way to define the `Content-Type` header is to use the `contentType` parameter to switch to the map of
// available body producers.
//
// For buffered requests, this setting override any `Content-Type` header possibly set by calling [Request.SetHeaderParam].
//
// For streamed requests, users may want more flexibility, as we enter custom territory, with use-cases not supported by OpenAPI v2.
//
// The `Content-Type` header of a streamed request is defined using the following sequence:
//
// 1. if the caller sets an explicit value already in header — the user set it via
// [Request.SetHeaderParam] during WriteToRequest, and we treat that as an intentional escape hatch
// 2. use payload's [runtime.ContentTyper] declaration (in this case, the produced payload knows its content type)
// 3. use `application/octet-stream` if it is available in the registered producers
// 4. otherwise set the picker's mediaType
//
// For multi-part requests, the content type of each part is auto-detected using the following sequence:
//
// 1. use [runtime.ContentTyper] declaration (in this case, the file payload knows its content type)
// 2. use [http.DetectContentType] on the first 512 bytes of the file
//
// # Concurrency
//
// A [Request] is a disposable object that is NOT intended to be reused or called concurrently.
//
// # Future evolutions
//
// There might be other similar structs that convert to other transports.
type Request struct {
pathPattern string
method string
writer runtime.ClientRequestWriter
pathParams map[string]string
header http.Header
query url.Values
formFields url.Values
fileFields map[string][]runtime.NamedReadCloser
payload any
// consumes carries the operation's full ConsumesMediaTypes list so
// that buildHTTP — which runs after the writer populates the payload
// — can apply payload-aware fallback rules (see streamFallbackMime).
//
// This is set by Runtime.createHttpRequest.
consumes []string
timeout time.Duration
buf *bytes.Buffer
getBody func(r *Request) []byte
}
// New creates a new http client [Request] to handle OpenAPI v2 parameters.
func New(method, pathPattern string, writer runtime.ClientRequestWriter) *Request {
return &Request{
pathPattern: pathPattern,
method: method,
writer: writer,
header: make(http.Header),
query: make(url.Values),
timeout: 0,
getBody: getRequestBuffer,
}
}
// GetMethod yields the method being used.
func (r *Request) GetMethod() string {
return r.method
}
// GetPath yields the URL path being used.
func (r *Request) GetPath() string {
pth := r.pathPattern
for k, v := range r.pathParams {
pth = strings.ReplaceAll(pth, "{"+k+"}", v)
}
return pth
}
// GetBody returns the request body, if any.
//
// For streaming requests, this is a copy of the original [io.Reader].
func (r *Request) GetBody() []byte {
return r.getBody(r)
}
// SetHeaderParam adds a header parameter to the request.
//
// The header key is always canonicalized.
//
// - when there is only 1 value provided, it will set it.
// - when there are several values provided, it will add all of those (no overriding).
func (r *Request) SetHeaderParam(name string, values ...string) error {
if r.header == nil {
r.header = make(http.Header)
}
r.header[http.CanonicalHeaderKey(name)] = values
return nil
}
// GetHeaderParams returns all headers currently set for the request.
func (r *Request) GetHeaderParams() http.Header {
return r.header
}
// SetQueryParam adds a query parameter to the request.
//
// - when there is only 1 value provided, it will set it.
// - when there are several values provided, it will add all of those (no overriding).
func (r *Request) SetQueryParam(name string, values ...string) error {
if r.query == nil {
r.query = make(url.Values)
}
r.query[name] = values
return nil
}
// GetQueryParams returns a copy of all query params currently set for the request.
func (r *Request) GetQueryParams() url.Values {
result := make(url.Values, len(r.query))
for key, values := range r.query {
result[key] = append([]string{}, values...)
}
return result
}
// SetFormParam adds a form param to the request.
//
// - when there is only 1 value provided, it will set it.
// - when there are several values provided, it will add all of those (no overriding).
func (r *Request) SetFormParam(name string, values ...string) error {
if r.formFields == nil {
r.formFields = make(url.Values)
}
r.formFields[name] = values
return nil
}
// SetPathParam adds a path param to the request.
func (r *Request) SetPathParam(name string, value string) error {
if r.pathParams == nil {
r.pathParams = make(map[string]string)
}
r.pathParams[name] = value
return nil
}
// SetFileParam adds a file parameter to the request.
//
// Files must implement [runtime.NamedReadCloser].
//
// [runtime.File] is proposed as the default concrete implementation.
func (r *Request) SetFileParam(name string, files ...runtime.NamedReadCloser) error {
for _, file := range files {
if actualFile, ok := file.(*os.File); ok {
fi, err := os.Stat(actualFile.Name())
if err != nil {
return err
}
if fi.IsDir() {
return fmt.Errorf("%q is a directory, only files are supported", file.Name())
}
}
}
if r.fileFields == nil {
r.fileFields = make(map[string][]runtime.NamedReadCloser)
}
if r.formFields == nil {
r.formFields = make(url.Values)
}
r.fileFields[name] = files
return nil
}
// GetFileParam yields all file parameters.
func (r *Request) GetFileParam() map[string][]runtime.NamedReadCloser {
return r.fileFields
}
// SetBodyParam sets a body parameter on the request.
//
// This does not yet serialize the object: actual serialization happens as late as possible.
func (r *Request) SetBodyParam(payload any) error {
r.payload = payload
return nil
}
// GetBodyParam returns the body payload.
func (r *Request) GetBodyParam() any {
return r.payload
}
// GetTimeout sets the timeout for a request.
func (r *Request) GetTimeout() time.Duration {
return r.timeout
}
// SetTimeout sets the timeout for a request.
func (r *Request) SetTimeout(timeout time.Duration) error {
r.timeout = timeout
return nil
}
// SetConsumes sets the list of registered consumed content for a request.
func (r *Request) SetConsumes(consumers []string) {
r.consumes = consumers
}
// BuildHTTPContext binds the request parameters and returns a ready-to-send [http.Request].
//
// Dispatch picks one of two end-to-end builders based on whether:
//
// - the body source is a stream (multipart pipe or stream payload)
// - or a buffer (urlencoded form, producer output, or no body)
//
// It starts by writing the request, then proceed with adding authentication,
// then finally assembling URL or header parameters.
//
// The split mirrors the auth question: streaming bodies require a lazy body-copy closure during [AuthenticateRequest],
// whereas buffered bodies do not.
//
// The returned [http.Request] carries a context derived from parentCtx that:
//
// - inherits any deadline or cancellation already set on parentCtx;
// - additionally honors the per-request timeout set via [Request.SetTimeout]
// (the [runtime.ClientRequestWriter] may override the runtime default during
// WriteToRequest, which is why the derivation happens here rather than
// at the call site).
//
// The returned cancel must be invoked by the caller (typically deferred)
// once the response has been fully read; otherwise resources held by the
// derived context — including any timeout timer — are leaked.
//
// On error the cancel is invoked internally and a no-op cancel is returned,
// so callers can defer cancel unconditionally.
func (r *Request) BuildHTTPContext(parentCtx context.Context, mediaType, basePath string,
producers map[string]runtime.Producer, registry strfmt.Registry, auth runtime.ClientAuthInfoWriter,
) (*http.Request, context.CancelFunc, error) {
if err := r.writer.WriteToRequest(r, registry); err != nil {
return nil, noop, err
}
ctx, cancel := deriveRequestContext(parentCtx, r.timeout)
r.buf = bytes.NewBuffer(nil)
var (
httpReq *http.Request
err error
)
if r.usesStreamingBody(mediaType) {
httpReq, err = r.buildStreamingRequest(ctx, mediaType, basePath, producers, registry, auth)
} else {
httpReq, err = r.buildBufferedRequest(ctx, mediaType, basePath, producers, registry, auth)
}
if err != nil {
cancel()
return nil, noop, err
}
return httpReq, cancel, nil
}
func noop() {}
// deriveRequestContext returns a child of parent bounded by timeout.
// If timeout == 0 the child is only canceled when the caller invokes
// cancel; any deadline already on parent is preserved. If timeout > 0
// the child uses the shortest of timeout and parent's existing deadline.
func deriveRequestContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout == 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, timeout)
}
// usesStreamingBody reports whether the request body must be assembled
// as a stream (an io.Pipe for multipart, or the payload's own reader
// for stream payloads).
//
// The complementary case is a fully buffered body in r.buf — urlencoded form, producer output, or no body at all.
func (r *Request) usesStreamingBody(mediaType string) bool {
if (len(r.formFields) > 0 || len(r.fileFields) > 0) && r.isMultipart(mediaType) {
return true
}
if r.payload != nil {
if _, ok := r.payload.(io.Reader); ok {
return true
}
}
return false
}
func (r *Request) isMultipart(mediaType string) bool {
// Strip media-type parameters before comparing: callers may legally
// pass `multipart/form-data; boundary=…` or
// `application/x-www-form-urlencoded; charset=utf-8` per RFC 7231,
// and a bare-string compare would route those to the wrong flow.
//
// mime.ParseMediaType lowercases the type/subtype and is
// case-insensitive on input, so plain == against our (lowercase)
// constants is sufficient on the happy path.
base, _, err := mime.ParseMediaType(mediaType)
if err != nil {
// Malformed mediaType: only the file-presence shortcut can
// fire — by definition we cannot recognize either canonical
// form mime in unparseable input.
return len(r.fileFields) > 0
}
// An explicit application/x-www-form-urlencoded choice is honored even when
// file fields are present: the spec allows files to travel as URL-encoded
// form values, although it does not stream and is discouraged. Without this
// short-circuit, picking urlencoded with files would silently fall back to
// multipart and emit an inconsistent Content-Type.
if base == runtime.URLencodedFormMime {
return false
}
if len(r.fileFields) > 0 {
return true
}
return base == runtime.MultipartFormMime
}
// buildBufferedRequest assembles a request whose body is fully
// buffered in r.buf before AuthenticateRequest runs — urlencoded form,
// producer-serialized payload, or no body.
//
// Auth is trivial in this flow because the buffer is already populated when the auth helper
// asks for the body via r.GetBody().
func (r *Request) buildBufferedRequest(ctx context.Context, mediaType, basePath string,
producers map[string]runtime.Producer, registry strfmt.Registry, auth runtime.ClientAuthInfoWriter,
) (*http.Request, error) {
var body io.Reader
var err error
switch {
case len(r.formFields) > 0 || len(r.fileFields) > 0:
body, err = r.writeURLEncodedBody(mediaType)
case r.payload != nil:
body, err = r.writeNonStreamPayload(mediaType, producers)
}
if err != nil {
return nil, err
}
if runtime.CanHaveBody(r.method) && body != nil && r.header.Get(runtime.HeaderContentType) == "" {
r.header.Set(runtime.HeaderContentType, mediaType)
}
if auth != nil {
if err := auth.AuthenticateRequest(r, registry); err != nil {
return nil, err
}
}
return r.assembleRequest(ctx, basePath, body)
}
// buildStreamingRequest assembles a request whose body is a stream —
// either an io.Pipe filled by the multipart goroutine, or the
// payload's own io.Reader.
//
// AuthenticateRequest consumes the body lazily through the getBody closure installed by
// applyAuthWithBodyCopy, which buffers the stream into r.buf so the http.Request can use the buffered copy.
//
// On any error path before the http.Request takes ownership of body, we close the body to release
// the underlying resource.
//
// For multipart this unblocks the spawned writer goroutine
// (it would otherwise park forever on pw.Write with no reader).
//
// For stream payloads it closes the user-provided io.ReadCloser.
func (r *Request) buildStreamingRequest(ctx context.Context, mediaType, basePath string,
producers map[string]runtime.Producer, registry strfmt.Registry, auth runtime.ClientAuthInfoWriter,
) (req *http.Request, retErr error) {
var body io.Reader
if len(r.formFields) > 0 || len(r.fileFields) > 0 {
body = r.writeMultipartBody(ctx, mediaType)
} else {
body = r.writeStreamPayload(mediaType, producers)
}
defer func() {
if retErr == nil {
return
}
if c, ok := body.(io.Closer); ok {
_ = c.Close()
}
}()
if runtime.CanHaveBody(r.method) && body != nil && r.header.Get(runtime.HeaderContentType) == "" {
r.header.Set(runtime.HeaderContentType, mediaType)
}
body, err := r.applyAuthWithBodyCopy(auth, body, registry)
if err != nil {
return nil, err
}
return r.assembleRequest(ctx, basePath, body)
}
// assembleRequest is the shared tail of both flows: build the URL
// path, create the http.Request, merge static query parameters, and
// finalize headers/query.
func (r *Request) assembleRequest(ctx context.Context, basePath string, body io.Reader) (*http.Request, error) {
urlPath, staticQueryParams, err := r.resolveURLPath(basePath)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, r.method, urlPath, body)
if err != nil {
return nil, err
}
if err := r.mergeStaticQuery(staticQueryParams); err != nil {
return nil, err
}
req.URL.RawQuery = r.query.Encode()
req.Header = r.header
return req, nil
}
// resolveURLPath builds the final url path string and returns the static
// query parameters extracted from basePath and r.pathPattern.
//
// Static query parameters from the path pattern take precedence over those
// from the base path; merging with r.query is the caller's responsibility
// (see [request.mergeStaticQuery]).
//
// The path is assembled from basePath + pathPattern with path-param
// substitution and trailing-slash preservation when the original
// pathPattern carried one.
func (r *Request) resolveURLPath(basePath string) (string, url.Values, error) {
basePathURL, err := url.Parse(basePath)
if err != nil {
return "", nil, err
}
staticQueryParams := basePathURL.Query()
pathPatternURL, err := url.Parse(r.pathPattern)
if err != nil {
return "", nil, err
}
for name, values := range pathPatternURL.Query() {
if _, present := staticQueryParams[name]; present {
staticQueryParams.Del(name)
}
for _, value := range values {
staticQueryParams.Add(name, value)
}
}
// path.Join strips trailing slashes; reinstate one whenever the
// pathPattern carried it, including the bare-root case ("/" under a
// non-empty basePath, which path.Join would collapse to "/basepath").
// The HasSuffix check on urlPath keeps the rewrite idempotent and
// avoids producing "//" when basePath is "/" or empty.
reinstateSlash := strings.HasSuffix(pathPatternURL.Path, "/")
urlPath := path.Join(basePathURL.Path, pathPatternURL.Path)
for k, v := range r.pathParams {
urlPath = strings.ReplaceAll(urlPath, "{"+k+"}", url.PathEscape(v))
}
if reinstateSlash && !strings.HasSuffix(urlPath, "/") {
urlPath += "/"
}
return urlPath, staticQueryParams, nil
}
// applyAuthWithBodyCopy runs auth.AuthenticateRequest for the
// streaming flow, where the http.Request body is a pipe or a payload
// reader rather than r.buf. If AuthenticateRequest asks for the body
// via r.GetBody(), the lazy closure copies the stream into r.buf on
// demand and reassigns body to r.buf so the post-auth source passed
// to http.NewRequestWithContext is the buffered copy.
//
// The closure is registered lazily because there is no way to know
// ahead of time whether AuthenticateRequest will read the body.
//
// On error precedence: a copy error is reported in preference to the
// AuthenticateRequest error, because a mis-read body may have
// interfered with auth.
//
// No-op when auth is nil; returns body unchanged.
func (r *Request) applyAuthWithBodyCopy(auth runtime.ClientAuthInfoWriter, body io.Reader, registry strfmt.Registry) (io.Reader, error) {
if auth == nil {
return body, nil
}
var copyErr error
var copied bool
r.getBody = func(r *Request) []byte {
if copied {
return getRequestBuffer(r)
}
defer func() {
copied = true
}()
if _, copyErr = io.Copy(r.buf, body); copyErr != nil {
return nil
}
if closer, ok := body.(io.ReadCloser); ok {
if copyErr = closer.Close(); copyErr != nil {
return nil
}
}
body = r.buf
return getRequestBuffer(r)
}
authErr := auth.AuthenticateRequest(r, registry)
// On error we return body alongside the error so the caller's
// cleanup defer (in buildStreamingRequest) can close the
// underlying pipe/stream. Caller treats body as ignorable when
// err != nil per Go convention; the defer reads it via closure.
if copyErr != nil {
return body, fmt.Errorf("error copying the request body: %w", copyErr)
}
if authErr != nil {
return body, authErr
}
return body, nil
}
// mergeStaticQuery overlays staticQuery onto r.query. On conflict r.query
// wins — the parameters set by the client take precedence over the ones
// extracted from basePath / pathPattern.
func (r *Request) mergeStaticQuery(staticQuery url.Values) error {
originalParams := r.GetQueryParams()
for k, v := range staticQuery {
if _, present := originalParams[k]; present {
continue
}
if err := r.SetQueryParam(k, v...); err != nil {
return err
}
}
return nil
}
// writeURLEncodedBody serializes form fields (and any file fields, per
// Swagger 2.0 fallback semantics) into r.buf as
// application/x-www-form-urlencoded. Sets Content-Type to mediaType and
// returns r.buf as the body source.
//
// Per Swagger 2.0, file form parameters can be sent under
// application/x-www-form-urlencoded by including the file content as a
// regular form-field value. The whole form is then percent-encoded as
// usual. This buffers the entire payload and does not preserve a
// per-file Content-Type — multipart/form-data is preferred when both
// are advertised by the operation.
func (r *Request) writeURLEncodedBody(mediaType string) (io.Reader, error) {
r.header.Set(runtime.HeaderContentType, mediaType)
values := url.Values{}
for k, vs := range r.formFields {
values[k] = append(values[k], vs...)
}
for fn, ff := range r.fileFields {
for _, fi := range ff {
data, ferr := io.ReadAll(fi)
if cerr := fi.Close(); cerr != nil && ferr == nil {
ferr = cerr
}
if ferr != nil {
return nil, ferr
}
values.Add(fn, string(data))
}
}
r.buf.WriteString(values.Encode())
return r.buf, nil
}
// writeMultipartBody assembles a multipart/form-data body via an
// io.Pipe. A goroutine streams form fields and files into the pipe
// writer; the pipe reader is returned as the body. Sets Content-Type to
// the multipart media type with the writer's boundary parameter.
//
// The goroutine owns the pipe writer's lifecycle: it closes the
// multipart writer (flushing the closing boundary) and the pipe writer
// when it finishes or hits an error.
func (r *Request) writeMultipartBody(ctx context.Context, mediaType string) io.Reader {
pr, pw := io.Pipe()
mp := multipart.NewWriter(pw)
r.header.Set(runtime.HeaderContentType, mangleContentType(mediaType, mp.Boundary()))
go r.streamMultipartParts(ctx, mp, pw)
return pr
}
// streamMultipartParts writes form fields then file fields to mp,
// closing mp and pw when done.
//
// Errors are reported by closing pw with the error so the consumer of pr observes them on its next Read.
//
// Context cancellation is observed at iteration boundaries (between
// fields and between files) and during file copy via a context-aware
// reader. When ctx is canceled the pipe writer is closed with ctx.Err()
// so the body consumer surfaces the cancellation as the read error.
func (r *Request) streamMultipartParts(ctx context.Context, mp *multipart.Writer, pw *io.PipeWriter) {
defer func() {
mp.Close()
pw.Close()
}()
for fn, v := range r.formFields {
for _, vi := range v {
if err := ctx.Err(); err != nil {
_ = pw.CloseWithError(err)
return
}
if err := mp.WriteField(fn, vi); err != nil {
logClose(err, pw)
return
}
}
}
defer func() {
for _, ff := range r.fileFields {
for _, ffi := range ff {
ffi.Close()
}
}
}()
for fn, f := range r.fileFields {
for _, fi := range f {
if err := ctx.Err(); err != nil {
_ = pw.CloseWithError(err)
return
}
var fileContentType string
if p, ok := fi.(runtime.ContentTyper); ok {
fileContentType = p.ContentType()
} else {
// Need to read the data so that we can detect the content type
const contentTypeBufferSize = 512
buf := make([]byte, contentTypeBufferSize)
size, err := fi.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
logClose(err, pw)
return
}
fileContentType = http.DetectContentType(buf)
fi = runtime.NamedReader(fi.Name(), io.MultiReader(bytes.NewReader(buf[:size]), fi))
}
// Create the MIME headers for the new part
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(fn), escapeQuotes(filepath.Base(fi.Name()))))
h.Set("Content-Type", fileContentType)
wrtr, err := mp.CreatePart(h)
if err != nil {
logClose(err, pw)
return
}
if _, err := io.Copy(wrtr, &ctxReader{ctx: ctx, r: fi}); err != nil {
logClose(err, pw)
return
}
}
}
}
// ctxReader wraps an [io.Reader] with a context check on each Read. Once
// ctx is done, subsequent Reads return ctx.Err() instead of delegating
// to the underlying reader. It does not preempt a Read already in flight
// — that is the source's responsibility (e.g. *os.File honors Close from
// another goroutine, network sources honor SetDeadline).
type ctxReader struct {
ctx context.Context //nolint:containedctx // io.Reader's Read method has no ctx parameter, so the wrapper must carry it on the struct
r io.Reader
}
func (cr *ctxReader) Read(p []byte) (int, error) {
if err := cr.ctx.Err(); err != nil {
return 0, err
}
return cr.r.Read(p)
}
// writeStreamPayload handles a stream payload (io.Reader /
// io.ReadCloser). The bytes flow through verbatim — no producer is
// invoked. The wire Content-Type is resolved via setStreamContentType
// (priority: existing header, payload's ContentTyper,
// streamFallbackMime, mediaType).
//
// Caller must ensure r.payload satisfies io.Reader (see
// [request.usesStreamingBody]).
func (r *Request) writeStreamPayload(mediaType string, producers map[string]runtime.Producer) io.Reader {
setStreamContentType(r.header, r.payload, mediaType, r.consumes, producers)
if rdr, ok := r.payload.(io.ReadCloser); ok {
return rdr
}
rdr, ok := r.payload.(io.Reader)
if !ok {
panic("internal error: payload expected to be an io.Reader") // guaranteed by earlier checks
}
return rdr
}
// writeNonStreamPayload runs the producer registered for mediaType
// against r.payload, writing into r.buf. The Content-Type header
// reflects the picker.
//
// SetHeaderParam("Content-Type", …) is intentionally NOT honored on
// the producer path because the producer is dispatched off mediaType —
// the wire header would otherwise misrepresent the body.
//
// The same reasoning applies to the form/multipart branch.
func (r *Request) writeNonStreamPayload(mediaType string, producers map[string]runtime.Producer) (io.Reader, error) {
r.header.Set(runtime.HeaderContentType, mediaType)
producer, ok := producers[mediaType]
if !ok {
return nil, fmt.Errorf("no producer registered for content type %q (register one with Runtime.Producers)", mediaType)
}
if err := producer.Produce(r.buf, r.payload); err != nil {
return nil, err
}
return r.buf, nil
}
var quoter = strings.NewReplacer(
"\\", "\\\\",
`"`, "\\\"",
"\r", "_",
"\n", "_",
)
// escapeQuotes escapes backslash and double-quote for embedding in a
// quoted-string Content-Disposition parameter value, and rewrites
// CR / LF to '_' to prevent header-injection through attacker-influenced
// field names or filenames.
//
// RFC 7578 §4.2 limits parameter values to printable characters; this
// is the conservative subset relevant to security (control characters
// that would split the header line into a forged header or part).
// Mirrors the known stdlib gap golang/go#19038.
func escapeQuotes(s string) string {
return quoter.Replace(s)
}
// setStreamContentType resolves and writes the wire Content-Type for a
// stream payload (io.Reader / io.ReadCloser). Priority:
//
// 1. an explicit value already in header — the user set it via
// SetHeaderParam during [ClientRequestWriter.WriteToRequest], and we treat that as an
// intentional escape hatch;
// 2. payload's [runtime.ContentTyper] declaration;
// 3. [streamFallbackMime] (Stage-2 octet-stream upgrade);
// 4. the picker's mediaType (passed in as the chain's terminal
// fallback).
//
// Does not apply to non-stream payloads or to form/multipart bodies —
// see the comment above the call site in [request.buildHTTP].
func setStreamContentType(
header http.Header,
payload any,
mediaType string,
candidates []string,
producers map[string]runtime.Producer,
) {
if header.Get(runtime.HeaderContentType) != "" {
return
}
fallback := streamFallbackMime(mediaType, candidates, producers)
header.Set(runtime.HeaderContentType, payloadContentType(payload, fallback))
}
// payloadContentType returns the payload's declared content type when
// it implements [runtime.ContentTyper] with a non-empty result, and
// fallback otherwise. Mirrors the per-file convention already used for
// multipart upload parts (see [request.buildHTTP] file-fields branch).
func payloadContentType(payload any, fallback string) string {
if t, ok := payload.(runtime.ContentTyper); ok {
if ct := t.ContentType(); ct != "" {
return ct
}
}
return fallback
}
// streamFallbackMime selects a wire content-type for a stream payload
// (io.Reader / io.ReadCloser) that has neither implemented
// `ContentType() string` nor declared an explicit value.
//
// The picker (Stage 1) ran without seeing the payload, so its choice
// may be wildly wrong for raw bytes — e.g. picking application/json
// for a payload that is just a stream of opaque data. When the
// candidate consumes list also offers application/octet-stream and
// the runtime has an octet-stream producer registered, that's a
// safer wire type than the picker's choice: it advertises "raw bytes"
// rather than making a structural claim about the body.
//
// If octet-stream is unavailable in either the candidate list or the
// producer set, the picker's choice is preserved. The wire header
// then continues to misrepresent the body — but no correct
// alternative exists and we cannot infer one without more
// information from the caller.
func streamFallbackMime(picked string, candidates []string, producers map[string]runtime.Producer) string {
if strings.EqualFold(picked, runtime.DefaultMime) {
return picked
}
for _, c := range candidates {
if strings.EqualFold(c, runtime.DefaultMime) {
if _, ok := producers[runtime.DefaultMime]; ok {
return runtime.DefaultMime
}
}
}
return picked
}
func getRequestBuffer(r *Request) []byte {
if r.buf == nil {
return nil
}
return r.buf.Bytes()
}
func logClose(err error, pw *io.PipeWriter) {
log.Println(err)
closeErr := pw.CloseWithError(err)
if closeErr != nil {
log.Println(closeErr)
}
}
func mangleContentType(mediaType, boundary string) string {
_ = mediaType // reserved for future enhancement: honor caller-provided media type
// Proposal for enhancement: honor caller's boundary if specified
return "multipart/form-data; boundary=" + boundary
}
+3 -3
View File
@@ -34,20 +34,20 @@ func (k *keepAliveTransport) RoundTrip(r *http.Request) (*http.Response, error)
type drainingReadCloser struct {
rdr io.ReadCloser
seenEOF uint32
seenEOF atomic.Uint32
}
func (d *drainingReadCloser) Read(p []byte) (n int, err error) {
n, err = d.rdr.Read(p)
if err == io.EOF || n == 0 {
atomic.StoreUint32(&d.seenEOF, 1)
d.seenEOF.Store(1)
}
return
}
func (d *drainingReadCloser) Close() error {
// drain buffer
if atomic.LoadUint32(&d.seenEOF) != 1 {
if d.seenEOF.Load() != 1 {
// If the reader side (a HTTP server) is misbehaving, it still may send
// some bytes, but the closer ignores them to keep the underling
// connection open.
+85 -8
View File
@@ -4,18 +4,20 @@
package client
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
"go.opentelemetry.io/otel/trace"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
)
const (
@@ -23,6 +25,52 @@ const (
tracerName = "go-openapi"
)
// WithOpenTelemetry adds opentelemetry support to the provided runtime.
// A new client span is created for each request.
// The provided opts are applied to each spans - for example to add global tags.
//
// The returned transport satisfies [runtime.ContextualTransport]: callers
// should prefer [openTelemetryTransport.SubmitContext] over the
// legacy [runtime.ClientOperation.Context] field. Setting that
// field is still honored on the [openTelemetryTransport.Submit]
// compatibility path.
func (r *Runtime) WithOpenTelemetry(opts ...OpenTelemetryOpt) runtime.ContextualTransport {
return newOpenTelemetryTransport(r, r.Host, opts)
}
// WithOpenTracing adds opentracing support to the provided runtime.
// A new client span is created for each request.
// If the context of the client operation does not contain an active span, no span is created.
// The provided opts are applied to each spans - for example to add global tags.
//
// Deprecated: use [WithOpenTelemetry] instead, as opentracing is now archived and superseded by opentelemetry.
//
// # Deprecation notice
//
// The [Runtime.WithOpenTracing] method has been deprecated in favor of [Runtime.WithOpenTelemetry].
//
// The method is still around so programs calling it will still build. However, it will return
// an opentelemetry transport.
//
// If you have a strict requirement on using opentracing, you may still do so by importing
// module [github.com/go-openapi/runtime/client-[middleware]/opentracing] and using
// [github.com/go-openapi/runtime/client-[middleware]/opentracing.WithOpenTracing] with your
// usual opentracing options and opentracing-enabled transport.
//
// Passed options are ignored unless they are of type [OpenTelemetryOpt].
func (r *Runtime) WithOpenTracing(opts ...any) runtime.ContextualTransport {
otelOpts := make([]OpenTelemetryOpt, 0, len(opts))
for _, o := range opts {
otelOpt, ok := o.(OpenTelemetryOpt)
if !ok {
continue
}
otelOpts = append(otelOpts, otelOpt)
}
return r.WithOpenTelemetry(otelOpts...)
}
type config struct {
Tracer trace.Tracer
Propagator propagation.TextMapPropagator
@@ -113,11 +161,31 @@ func newOpenTelemetryTransport(transport runtime.ClientTransport, host string, o
return tr
}
// Submit implements [runtime.ClientTransport]. It honors the legacy
// [runtime.ClientOperation.Context] field for backward compatibility
// — that field is being phased out; new code should call
// [openTelemetryTransport.SubmitContext] directly with an explicit
// context.
func (t *openTelemetryTransport) Submit(op *runtime.ClientOperation) (any, error) {
if op.Context == nil {
return t.transport.Submit(op)
ctx := op.Context
if ctx == nil {
ctx = context.Background()
}
return t.SubmitContext(ctx, op)
}
// SubmitContext submits an operation with an explicit context that
// drives both the tracing span and (when supported) the wrapped
// transport's SubmitContext call. The legacy
// [runtime.ClientOperation.Context] field is not consulted.
//
// When the wrapped transport implements [runtime.ContextualTransport], ctx is
// forwarded directly via its SubmitContext. Otherwise, the legacy
// Submit path is used: ctx is stamped onto op.Context for the
// duration of that call and restored afterwards, so the wrapped
// transport still receives a usable context. The legacy fallback
// disappears once SubmitContext is universal (v2).
func (t *openTelemetryTransport) SubmitContext(ctx context.Context, op *runtime.ClientOperation) (any, error) {
params := op.Params
reader := op.Reader
@@ -129,7 +197,7 @@ func (t *openTelemetryTransport) Submit(op *runtime.ClientOperation) (any, error
}()
op.Params = runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
span = t.newOpenTelemetrySpan(op, req.GetHeaderParams())
span = t.newOpenTelemetrySpan(ctx, op, req.GetHeaderParams())
return params.WriteToRequest(req, reg)
})
@@ -149,7 +217,7 @@ func (t *openTelemetryTransport) Submit(op *runtime.ClientOperation) (any, error
return reader.ReadResponse(response, consumer)
})
submit, err := t.transport.Submit(op)
submit, err := t.submitWrapped(ctx, op)
if err != nil && span != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -158,9 +226,18 @@ func (t *openTelemetryTransport) Submit(op *runtime.ClientOperation) (any, error
return submit, err
}
func (t *openTelemetryTransport) newOpenTelemetrySpan(op *runtime.ClientOperation, header http.Header) trace.Span {
ctx := op.Context
//nolint:contextcheck // ctx is forwarded verbatim; the legacy Submit branch only stamps it onto op.Context for the wrapped transport.
func (t *openTelemetryTransport) submitWrapped(ctx context.Context, op *runtime.ClientOperation) (any, error) {
if sc, ok := t.transport.(runtime.ContextualTransport); ok {
return sc.SubmitContext(ctx, op)
}
prev := op.Context
op.Context = ctx
defer func() { op.Context = prev }()
return t.transport.Submit(op)
}
func (t *openTelemetryTransport) newOpenTelemetrySpan(ctx context.Context, op *runtime.ClientOperation, header http.Header) trace.Span {
tracer := t.tracer
if tracer == nil {
if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
-468
View File
@@ -1,468 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package client
import (
"bytes"
"context"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
)
var _ runtime.ClientRequest = new(request) // ensure compliance to the interface
// Request represents a swagger client request.
//
// This Request struct converts to a HTTP request.
// There might be others that convert to other transports.
// There is no error checking here, it is assumed to be used after a spec has been validated.
// so impossible combinations should not arise (hopefully).
//
// The main purpose of this struct is to hide the machinery of adding params to a transport request.
// The generated code only implements what is necessary to turn a param into a valid value for these methods.
type request struct {
pathPattern string
method string
writer runtime.ClientRequestWriter
pathParams map[string]string
header http.Header
query url.Values
formFields url.Values
fileFields map[string][]runtime.NamedReadCloser
payload any
timeout time.Duration
buf *bytes.Buffer
getBody func(r *request) []byte
}
// NewRequest creates a new swagger http client request.
func newRequest(method, pathPattern string, writer runtime.ClientRequestWriter) *request {
return &request{
pathPattern: pathPattern,
method: method,
writer: writer,
header: make(http.Header),
query: make(url.Values),
timeout: DefaultTimeout,
getBody: getRequestBuffer,
}
}
// BuildHTTP creates a new http request based on the data from the params.
func (r *request) BuildHTTP(mediaType, basePath string, producers map[string]runtime.Producer, registry strfmt.Registry) (*http.Request, error) {
return r.buildHTTP(mediaType, basePath, producers, registry, nil)
}
func (r *request) GetMethod() string {
return r.method
}
func (r *request) GetPath() string {
path := r.pathPattern
for k, v := range r.pathParams {
path = strings.ReplaceAll(path, "{"+k+"}", v)
}
return path
}
func (r *request) GetBody() []byte {
return r.getBody(r)
}
// SetHeaderParam adds a header param to the request
// when there is only 1 value provided for the varargs, it will set it.
// when there are several values provided for the varargs it will add it (no overriding).
func (r *request) SetHeaderParam(name string, values ...string) error {
if r.header == nil {
r.header = make(http.Header)
}
r.header[http.CanonicalHeaderKey(name)] = values
return nil
}
// GetHeaderParams returns the all headers currently set for the request.
func (r *request) GetHeaderParams() http.Header {
return r.header
}
// SetQueryParam adds a query param to the request
// when there is only 1 value provided for the varargs, it will set it.
// when there are several values provided for the varargs it will add it (no overriding).
func (r *request) SetQueryParam(name string, values ...string) error {
if r.query == nil {
r.query = make(url.Values)
}
r.query[name] = values
return nil
}
// GetQueryParams returns a copy of all query params currently set for the request.
func (r *request) GetQueryParams() url.Values {
var result = make(url.Values)
for key, value := range r.query {
result[key] = append([]string{}, value...)
}
return result
}
// SetFormParam adds a forn param to the request
// when there is only 1 value provided for the varargs, it will set it.
// when there are several values provided for the varargs it will add it (no overriding).
func (r *request) SetFormParam(name string, values ...string) error {
if r.formFields == nil {
r.formFields = make(url.Values)
}
r.formFields[name] = values
return nil
}
// SetPathParam adds a path param to the request.
func (r *request) SetPathParam(name string, value string) error {
if r.pathParams == nil {
r.pathParams = make(map[string]string)
}
r.pathParams[name] = value
return nil
}
// SetFileParam adds a file param to the request.
func (r *request) SetFileParam(name string, files ...runtime.NamedReadCloser) error {
for _, file := range files {
if actualFile, ok := file.(*os.File); ok {
fi, err := os.Stat(actualFile.Name())
if err != nil {
return err
}
if fi.IsDir() {
return fmt.Errorf("%q is a directory, only files are supported", file.Name())
}
}
}
if r.fileFields == nil {
r.fileFields = make(map[string][]runtime.NamedReadCloser)
}
if r.formFields == nil {
r.formFields = make(url.Values)
}
r.fileFields[name] = files
return nil
}
func (r *request) GetFileParam() map[string][]runtime.NamedReadCloser {
return r.fileFields
}
// SetBodyParam sets a body parameter on the request.
// This does not yet serialze the object, this happens as late as possible.
func (r *request) SetBodyParam(payload any) error {
r.payload = payload
return nil
}
func (r *request) GetBodyParam() any {
return r.payload
}
// SetTimeout sets the timeout for a request.
func (r *request) SetTimeout(timeout time.Duration) error {
r.timeout = timeout
return nil
}
func (r *request) isMultipart(mediaType string) bool {
if len(r.fileFields) > 0 {
return true
}
return runtime.MultipartFormMime == mediaType
}
func (r *request) buildHTTP(mediaType, basePath string, producers map[string]runtime.Producer, registry strfmt.Registry, auth runtime.ClientAuthInfoWriter) (*http.Request, error) { //nolint:gocyclo,maintidx
// build the data
if err := r.writer.WriteToRequest(r, registry); err != nil {
return nil, err
}
// Our body must be an io.Reader.
// When we create the http.Request, if we pass it a
// bytes.Buffer then it will wrap it in an io.ReadCloser
// and set the content length automatically.
var body io.Reader
var pr *io.PipeReader
var pw *io.PipeWriter
r.buf = bytes.NewBuffer(nil)
if r.payload != nil || len(r.formFields) > 0 || len(r.fileFields) > 0 {
body = r.buf
if r.isMultipart(mediaType) {
pr, pw = io.Pipe()
body = pr
}
}
// check if this is a form type request
if len(r.formFields) > 0 || len(r.fileFields) > 0 {
if !r.isMultipart(mediaType) {
r.header.Set(runtime.HeaderContentType, mediaType)
formString := r.formFields.Encode()
r.buf.WriteString(formString)
goto DoneChoosingBodySource
}
mp := multipart.NewWriter(pw)
r.header.Set(runtime.HeaderContentType, mangleContentType(mediaType, mp.Boundary()))
go func() {
defer func() {
mp.Close()
pw.Close()
}()
for fn, v := range r.formFields {
for _, vi := range v {
if err := mp.WriteField(fn, vi); err != nil {
logClose(err, pw)
return
}
}
}
defer func() {
for _, ff := range r.fileFields {
for _, ffi := range ff {
ffi.Close()
}
}
}()
for fn, f := range r.fileFields {
for _, fi := range f {
var fileContentType string
if p, ok := fi.(interface {
ContentType() string
}); ok {
fileContentType = p.ContentType()
} else {
// Need to read the data so that we can detect the content type
const contentTypeBufferSize = 512
buf := make([]byte, contentTypeBufferSize)
size, err := fi.Read(buf)
if err != nil && err != io.EOF {
logClose(err, pw)
return
}
fileContentType = http.DetectContentType(buf)
fi = runtime.NamedReader(fi.Name(), io.MultiReader(bytes.NewReader(buf[:size]), fi))
}
// Create the MIME headers for the new part
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(fn), escapeQuotes(filepath.Base(fi.Name()))))
h.Set("Content-Type", fileContentType)
wrtr, err := mp.CreatePart(h)
if err != nil {
logClose(err, pw)
return
}
if _, err := io.Copy(wrtr, fi); err != nil {
logClose(err, pw)
}
}
}
}()
goto DoneChoosingBodySource
}
// if there is payload, use the producer to write the payload, and then
// set the header to the content-type appropriate for the payload produced
if r.payload != nil {
// Enhancement proposal: https://github.com/go-openapi/runtime/issues/387
r.header.Set(runtime.HeaderContentType, mediaType)
if rdr, ok := r.payload.(io.ReadCloser); ok {
body = rdr
goto DoneChoosingBodySource
}
if rdr, ok := r.payload.(io.Reader); ok {
body = rdr
goto DoneChoosingBodySource
}
producer := producers[mediaType]
if err := producer.Produce(r.buf, r.payload); err != nil {
return nil, err
}
}
DoneChoosingBodySource:
if runtime.CanHaveBody(r.method) && body != nil && r.header.Get(runtime.HeaderContentType) == "" {
r.header.Set(runtime.HeaderContentType, mediaType)
}
if auth != nil {
// If we're not using r.buf as our http.Request's body,
// either the payload is an io.Reader or io.ReadCloser,
// or we're doing a multipart form/file.
//
// In those cases, if the AuthenticateRequest call asks for the body,
// we must read it into a buffer and provide that, then use that buffer
// as the body of our http.Request.
//
// This is done in-line with the GetBody() request rather than ahead
// of time, because there's no way to know if the AuthenticateRequest
// will even ask for the body of the request.
//
// If for some reason the copy fails, there's no way to return that
// error to the GetBody() call, so return it afterwards.
//
// An error from the copy action is prioritized over any error
// from the AuthenticateRequest call, because the mis-read
// body may have interfered with the auth.
//
var copyErr error
if buf, ok := body.(*bytes.Buffer); body != nil && (!ok || buf != r.buf) {
var copied bool
r.getBody = func(r *request) []byte {
if copied {
return getRequestBuffer(r)
}
defer func() {
copied = true
}()
if _, copyErr = io.Copy(r.buf, body); copyErr != nil {
return nil
}
if closer, ok := body.(io.ReadCloser); ok {
if copyErr = closer.Close(); copyErr != nil {
return nil
}
}
body = r.buf
return getRequestBuffer(r)
}
}
authErr := auth.AuthenticateRequest(r, registry)
if copyErr != nil {
return nil, fmt.Errorf("error retrieving the response body: %v", copyErr)
}
if authErr != nil {
return nil, authErr
}
}
// In case the basePath or the request pathPattern include static query parameters,
// parse those out before constructing the final path. The parameters themselves
// will be merged with the ones set by the client, with the priority given first to
// the ones set by the client, then the path pattern, and lastly the base path.
basePathURL, err := url.Parse(basePath)
if err != nil {
return nil, err
}
staticQueryParams := basePathURL.Query()
pathPatternURL, err := url.Parse(r.pathPattern)
if err != nil {
return nil, err
}
for name, values := range pathPatternURL.Query() {
if _, present := staticQueryParams[name]; present {
staticQueryParams.Del(name)
}
for _, value := range values {
staticQueryParams.Add(name, value)
}
}
// create http request
var reinstateSlash bool
if pathPatternURL.Path != "" && pathPatternURL.Path != "/" && pathPatternURL.Path[len(pathPatternURL.Path)-1] == '/' {
reinstateSlash = true
}
urlPath := path.Join(basePathURL.Path, pathPatternURL.Path)
for k, v := range r.pathParams {
urlPath = strings.ReplaceAll(urlPath, "{"+k+"}", url.PathEscape(v))
}
if reinstateSlash {
urlPath += "/"
}
req, err := http.NewRequestWithContext(context.Background(), r.method, urlPath, body)
if err != nil {
return nil, err
}
originalParams := r.GetQueryParams()
// Merge the query parameters extracted from the basePath with the ones set by
// the client in this struct. In case of conflict, the client wins.
for k, v := range staticQueryParams {
_, present := originalParams[k]
if !present {
if err = r.SetQueryParam(k, v...); err != nil {
return nil, err
}
}
}
req.URL.RawQuery = r.query.Encode()
req.Header = r.header
return req, nil
}
func escapeQuotes(s string) string {
return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(s)
}
func getRequestBuffer(r *request) []byte {
if r.buf == nil {
return nil
}
return r.buf.Bytes()
}
func logClose(err error, pw *io.PipeWriter) {
log.Println(err)
closeErr := pw.CloseWithError(err)
if closeErr != nil {
log.Println(closeErr)
}
}
func mangleContentType(mediaType, boundary string) string {
if strings.ToLower(mediaType) == runtime.URLencodedFormMime {
return fmt.Sprintf("%s; boundary=%s", mediaType, boundary)
}
return "multipart/form-data; boundary=" + boundary
}
+305 -339
View File
@@ -5,25 +5,19 @@ package client
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"mime"
"net/http"
"net/http/httputil"
"os"
"strings"
"sync"
"time"
"github.com/go-openapi/runtime"
"github.com/go-openapi/runtime/client/internal/request"
"github.com/go-openapi/runtime/logger"
"github.com/go-openapi/runtime/middleware"
"github.com/go-openapi/runtime/server-middleware/mediatype"
"github.com/go-openapi/runtime/yamlpc"
"github.com/go-openapi/strfmt"
)
@@ -36,184 +30,6 @@ const (
// DefaultTimeout the default request timeout.
var DefaultTimeout = 30 * time.Second
// TLSClientOptions to configure client authentication with mutual TLS.
type TLSClientOptions struct {
// Certificate is the path to a PEM-encoded certificate to be used for
// client authentication. If set then Key must also be set.
Certificate string
// LoadedCertificate is the certificate to be used for client authentication.
// This field is ignored if Certificate is set. If this field is set, LoadedKey
// is also required.
LoadedCertificate *x509.Certificate
// Key is the path to an unencrypted PEM-encoded private key for client
// authentication. This field is required if Certificate is set.
Key string
// LoadedKey is the key for client authentication. This field is required if
// LoadedCertificate is set.
LoadedKey crypto.PrivateKey
// CA is a path to a PEM-encoded certificate that specifies the root certificate
// to use when validating the TLS certificate presented by the server. If this field
// (and LoadedCA) is not set, the system certificate pool is used. This field is ignored if LoadedCA
// is set.
CA string
// LoadedCA specifies the root certificate to use when validating the server's TLS certificate.
// If this field (and CA) is not set, the system certificate pool is used.
LoadedCA *x509.Certificate
// LoadedCAPool specifies a pool of RootCAs to use when validating the server's TLS certificate.
// If set, it will be combined with the other loaded certificates (see LoadedCA and CA).
// If neither LoadedCA or CA is set, the provided pool with override the system
// certificate pool.
// The caller must not use the supplied pool after calling TLSClientAuth.
LoadedCAPool *x509.CertPool
// ServerName specifies the hostname to use when verifying the server certificate.
// If this field is set then InsecureSkipVerify will be ignored and treated as
// false.
ServerName string
// InsecureSkipVerify controls whether the certificate chain and hostname presented
// by the server are validated. If true, any certificate is accepted.
InsecureSkipVerify bool
// VerifyPeerCertificate, if not nil, is called after normal
// certificate verification. It receives the raw ASN.1 certificates
// provided by the peer and also any verified chains that normal processing found.
// If it returns a non-nil error, the handshake is aborted and that error results.
//
// If normal verification fails then the handshake will abort before
// considering this callback. If normal verification is disabled by
// setting InsecureSkipVerify then this callback will be considered but
// the verifiedChains argument will always be nil.
VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
// VerifyConnection, if not nil, is called after normal certificate
// verification and after [TLSClientOptions.VerifyPeerCertificate] by either a TLS client or
// server. It receives the [tls.ConnectionState] which may be inspected.
//
// Unlike VerifyPeerCertificate, this callback is invoked on every
// connection, including resumed ones, making it suitable for checks
// that must always apply (e.g. certificate pinning).
//
// If it returns a non-nil error, the handshake is aborted and that error results.
VerifyConnection func(tls.ConnectionState) error
// SessionTicketsDisabled may be set to true to disable session ticket and
// PSK (resumption) support. Note that on clients, session ticket support is
// also disabled if ClientSessionCache is nil.
SessionTicketsDisabled bool
// ClientSessionCache is a cache of ClientSessionState entries for TLS
// session resumption. It is only used by clients.
ClientSessionCache tls.ClientSessionCache
// Prevents callers using unkeyed fields.
_ struct{}
}
// TLSClientAuth creates a [tls.Config] for mutual auth.
func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) {
// create client tls config
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
// load client cert if specified
if opts.Certificate != "" {
cert, err := tls.LoadX509KeyPair(opts.Certificate, opts.Key)
if err != nil {
return nil, fmt.Errorf("tls client cert: %v", err)
}
cfg.Certificates = []tls.Certificate{cert}
} else if opts.LoadedCertificate != nil {
block := pem.Block{Type: "CERTIFICATE", Bytes: opts.LoadedCertificate.Raw}
certPem := pem.EncodeToMemory(&block)
var keyBytes []byte
switch k := opts.LoadedKey.(type) {
case *rsa.PrivateKey:
keyBytes = x509.MarshalPKCS1PrivateKey(k)
case *ecdsa.PrivateKey:
var err error
keyBytes, err = x509.MarshalECPrivateKey(k)
if err != nil {
return nil, fmt.Errorf("tls client priv key: %v", err)
}
default:
return nil, errors.New("tls client priv key: unsupported key type")
}
block = pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}
keyPem := pem.EncodeToMemory(&block)
cert, err := tls.X509KeyPair(certPem, keyPem)
if err != nil {
return nil, fmt.Errorf("tls client cert: %v", err)
}
cfg.Certificates = []tls.Certificate{cert}
}
cfg.InsecureSkipVerify = opts.InsecureSkipVerify
cfg.VerifyPeerCertificate = opts.VerifyPeerCertificate
cfg.VerifyConnection = opts.VerifyConnection
cfg.SessionTicketsDisabled = opts.SessionTicketsDisabled
cfg.ClientSessionCache = opts.ClientSessionCache
// When no CA certificate is provided, default to the system cert pool
// that way when a request is made to a server known by the system trust store,
// the name is still verified
switch {
case opts.LoadedCA != nil:
caCertPool := basePool(opts.LoadedCAPool)
caCertPool.AddCert(opts.LoadedCA)
cfg.RootCAs = caCertPool
case opts.CA != "":
// load ca cert
caCert, err := os.ReadFile(opts.CA)
if err != nil {
return nil, fmt.Errorf("tls client ca: %v", err)
}
caCertPool := basePool(opts.LoadedCAPool)
caCertPool.AppendCertsFromPEM(caCert)
cfg.RootCAs = caCertPool
case opts.LoadedCAPool != nil:
cfg.RootCAs = opts.LoadedCAPool
}
// apply servername overrride
if opts.ServerName != "" {
cfg.InsecureSkipVerify = false
cfg.ServerName = opts.ServerName
}
return cfg, nil
}
// TLSTransport creates a [http] client transport suitable for mutual [tls] auth.
func TLSTransport(opts TLSClientOptions) (http.RoundTripper, error) {
cfg, err := TLSClientAuth(opts)
if err != nil {
return nil, err
}
return &http.Transport{TLSClientConfig: cfg}, nil
}
// TLSClient creates a [http.Client] for mutual auth.
func TLSClient(opts TLSClientOptions) (*http.Client, error) {
transport, err := TLSTransport(opts)
if err != nil {
return nil, err
}
return &http.Client{Transport: transport}, nil
}
// Runtime represents an API client that uses the transport
// to make [http] requests based on a swagger specification.
type Runtime struct {
@@ -228,17 +44,50 @@ type Runtime struct {
Host string
BasePath string
Formats strfmt.Registry
Context context.Context //nolint:containedctx // we precisely want this type to contain the request context
// Deprecated: prefer [runtime.ContextualTransport.SubmitContext] to pass the request context explicitly.
Context context.Context //nolint:containedctx // we precisely want this type to contain the request context
Debug bool
// Trace enables connection-level diagnostic output via
// [net/http/httptrace]. When true, the runtime narrates the
// connection lifecycle of every request through r.logger.Debugf:
// DNS, dial, TLS handshake, idle-pool reuse, request body
// transfer, time-to-first-byte, response body transfer, and a
// trailing per-request summary line.
//
// Trace is orthogonal to Debug: Debug dumps wire bytes (request
// and response headers and body), Trace narrates how the
// connection got there. Both may be enabled independently.
//
// Trace is not coupled to the SWAGGER_DEBUG / DEBUG environment
// variables: it defaults to false and is only enabled by
// explicit assignment.
//
// Trace is primarily intended as a problem-investigation tool
// (the local equivalent of curl -vvv), not an always-on tracer.
// For distributed-trace correlation, use the OpenTelemetry
// integration ([Runtime.WithOpenTelemetry]).
Trace bool
Debug bool
logger logger.Logger
// MatchSuffix enables RFC 6839 structured-syntax suffix tolerance
// for codec lookup. When true, a response with Content-Type
// "application/problem+json" finds the JSON consumer registered
// under "application/json"; with the default false, the lookup
// is strict and falls through to the "*/*" wildcard if present.
// See [mediatype.AllowSuffix] for the semantics.
MatchSuffix bool
clientOnce *sync.Once
client *http.Client
schemes []string
response ClientResponseFunc
}
var _ runtime.ContextualTransport = &Runtime{}
// New creates a new default runtime for a swagger api runtime.Client.
func New(host, basePath string, schemes []string) *Runtime {
var rt Runtime
@@ -246,13 +95,15 @@ func New(host, basePath string, schemes []string) *Runtime {
// Enhancement proposal: https://github.com/go-openapi/runtime/issues/385
rt.Consumers = map[string]runtime.Consumer{
runtime.YAMLMime: yamlpc.YAMLConsumer(),
runtime.JSONMime: runtime.JSONConsumer(),
runtime.XMLMime: runtime.XMLConsumer(),
runtime.TextMime: runtime.TextConsumer(),
runtime.HTMLMime: runtime.TextConsumer(),
runtime.CSVMime: runtime.CSVConsumer(),
runtime.DefaultMime: runtime.ByteStreamConsumer(),
runtime.YAMLMime: yamlpc.YAMLConsumer(),
runtime.JSONMime: runtime.JSONConsumer(),
runtime.XMLMime: runtime.XMLConsumer(),
runtime.TextMime: runtime.TextConsumer(),
runtime.HTMLMime: runtime.TextConsumer(),
runtime.CSVMime: runtime.CSVConsumer(),
runtime.MultipartFormMime: runtime.ByteStreamConsumer(),
runtime.URLencodedFormMime: runtime.ByteStreamConsumer(),
runtime.DefaultMime: runtime.ByteStreamConsumer(),
}
rt.Producers = map[string]runtime.Producer{
runtime.YAMLMime: yamlpc.YAMLProducer(),
@@ -294,47 +145,6 @@ func NewWithClient(host, basePath string, schemes []string, client *http.Client)
return rt
}
// WithOpenTracing adds opentracing support to the provided runtime.
// A new client span is created for each request.
// If the context of the client operation does not contain an active span, no span is created.
// The provided opts are applied to each spans - for example to add global tags.
//
// Deprecated: use [WithOpenTelemetry] instead, as opentracing is now archived and superseded by opentelemetry.
//
// # Deprecation notice
//
// The [Runtime.WithOpenTracing] method has been deprecated in favor of [Runtime.WithOpenTelemetry].
//
// The method is still around so programs calling it will still build. However, it will return
// an opentelemetry transport.
//
// If you have a strict requirement on using opentracing, you may still do so by importing
// module [github.com/go-openapi/runtime/client-[middleware]/opentracing] and using
// [github.com/go-openapi/runtime/client-[middleware]/opentracing.WithOpenTracing] with your
// usual opentracing options and opentracing-enabled transport.
//
// Passed options are ignored unless they are of type [OpenTelemetryOpt].
func (r *Runtime) WithOpenTracing(opts ...any) runtime.ClientTransport {
otelOpts := make([]OpenTelemetryOpt, 0, len(opts))
for _, o := range opts {
otelOpt, ok := o.(OpenTelemetryOpt)
if !ok {
continue
}
otelOpts = append(otelOpts, otelOpt)
}
return r.WithOpenTelemetry(otelOpts...)
}
// WithOpenTelemetry adds opentelemetry support to the provided runtime.
// A new client span is created for each request.
// If the context of the client operation does not contain an active span, no span is created.
// The provided opts are applied to each spans - for example to add global tags.
func (r *Runtime) WithOpenTelemetry(opts ...OpenTelemetryOpt) runtime.ClientTransport {
return newOpenTelemetryTransport(r, r.Host, opts)
}
// EnableConnectionReuse drains the remaining body from a response
// so that go will reuse the TCP connections.
//
@@ -357,105 +167,109 @@ func (r *Runtime) EnableConnectionReuse() {
)
}
// CreateHTTPRequestContext creates the requests and bind the parameters, but does not send it over the wire
// like [Runtime.SubmitContext].
//
// The [http.Request] is complete with authentication, headers and body (including streamed body) and ready for callers
// to submit it to a [http.Client] of their choice, then consume the [http.Response].
//
// Most users would simply use [Runtime.SubmitContext], which wraps all these operations in one call.
func (r *Runtime) CreateHTTPRequestContext(ctx context.Context, operation *runtime.ClientOperation) (req *http.Request, cancel context.CancelFunc, err error) {
req, cancel, err = r.createHTTPRequestContext(ctx, operation)
return
}
// CreateHttpRequest builds the [http.Request] for the given operation, using
// [context.Background] as the request context.
//
// Any per-operation timeout declared by the operation's [runtime.ClientRequestWriter]
// is silently ignored here, which can leak a context-cancellation channel if the
// caller relies on it.
//
// Deprecated: use [Runtime.CreateHTTPRequestContext] instead, with explicit
// control over the request context and its cancellation.
func (r *Runtime) CreateHttpRequest(operation *runtime.ClientOperation) (req *http.Request, err error) { //nolint:revive
_, req, err = r.createHttpRequest(operation)
req, _, err = r.createHTTPRequestContext(context.Background(), operation)
return
}
// Submit a request and when there is a body on success it will turn that into the result
// all other things are turned into an api error for swagger which retains the status code.
//
// This call inherits the context possibly put in the operation, otherwise the one possibly put in the [Runtime].
// If none are set, use [context.Background].
//
// Any timeout set by parameters is honored.
func (r *Runtime) Submit(operation *runtime.ClientOperation) (any, error) {
_, readResponse, _ := operation.Params, operation.Reader, operation.AuthInfo
return r.SubmitContext(r.ensureContext(operation), operation)
}
request, req, err := r.createHttpRequest(operation)
// SubmitContext submits a request and returns the result.
//
// Errors are turned into an api error for swagger which retains the status code.
//
// Unlike [Submit], [SubmitContext] only injects the context provided by the caller:
// contexts possibly cached in operation or runtime are ignored.
//
// On the other hand, a timeout set by parameters is honored.
func (r *Runtime) SubmitContext(parentCtx context.Context, operation *runtime.ClientOperation) (any, error) {
req, cancel, err := r.createHTTPRequestContext(parentCtx, operation)
if err != nil {
return nil, err
}
r.clientOnce.Do(func() {
r.client = &http.Client{
Transport: r.Transport,
Jar: r.Jar,
}
})
if r.Debug {
b, err2 := httputil.DumpRequestOut(req, true)
if err2 != nil {
return nil, err2
}
r.logger.Debugf("%s\n", string(b))
}
var parentCtx context.Context
switch {
case operation.Context != nil:
parentCtx = operation.Context
case r.Context != nil:
parentCtx = r.Context
default:
parentCtx = context.Background()
}
var (
ctx context.Context
cancel context.CancelFunc
)
if request.timeout == 0 {
// There may be a deadline in the context passed to the operation.
// Otherwise, there is no timeout set.
ctx, cancel = context.WithCancel(parentCtx)
} else {
// Sets the timeout passed from request params (by default runtime.DefaultTimeout).
// If there is already a deadline in the parent context, the shortest will
// apply.
ctx, cancel = context.WithTimeout(parentCtx, request.timeout)
}
defer cancel()
var client *http.Client
if operation.Client != nil {
client = operation.Client
} else {
client = r.client
r.ensureClient()
if err := r.dumpRequest(req); err != nil {
return nil, err
}
req = req.WithContext(ctx)
res, err := client.Do(req) // make requests, by default follows 10 redirects before failing
// Attach the trace session before Do so the httptrace hooks
// fire during the round-trip. The session emits its trailing
// summary on finish; the response body is consumed by
// ReadResponse downstream, after which finish is called.
var trace *traceSession
if r.Trace {
trace = newTraceSession(r.logger, req.Method, req.URL.String(),
introspectTLSConfig(r.pickClient(operation)))
//nolint:contextcheck // We intentionally derive from req.Context() to layer the trace hooks onto the existing request context.
req = req.WithContext(trace.attach(req.Context()))
if req.Body != nil {
req.Body = trace.wrapRequestBody(req.Body)
}
defer trace.finish()
}
res, err := r.pickClient(operation).Do(req)
if err != nil {
if trace != nil {
trace.onRoundTripError(err)
}
return nil, err
}
defer res.Body.Close()
if trace != nil {
trace.onResponse(res.StatusCode)
res.Body = trace.wrapResponseBody(res.Body)
}
ct := res.Header.Get(runtime.HeaderContentType)
if ct == "" { // this should really never occur
ct = r.DefaultMediaType
}
if r.Debug {
printBody := true
if ct == runtime.DefaultMime {
printBody = false // Spare the terminal from a binary blob.
}
b, err2 := httputil.DumpResponse(res, printBody)
if err2 != nil {
return nil, err2
}
r.logger.Debugf("%s\n", string(b))
if err := r.dumpResponse(res, ct); err != nil {
return nil, err
}
mt, _, err := mime.ParseMediaType(ct)
cons, err := r.resolveConsumer(ct)
if err != nil {
return nil, fmt.Errorf("parse content type: %s", err)
return nil, err
}
cons, ok := r.Consumers[mt]
if !ok {
if cons, ok = r.Consumers["*/*"]; !ok {
// scream about not knowing what to do
return nil, fmt.Errorf("no consumer: %q", ct)
}
}
return readResponse.ReadResponse(r.response(res), cons)
return operation.Reader.ReadResponse(r.response(res), cons)
}
// SetDebug changes the debug flag.
@@ -482,6 +296,17 @@ func (r *Runtime) SetResponseReader(f ClientResponseFunc) {
r.response = f
}
func (r *Runtime) ensureContext(operation *runtime.ClientOperation) context.Context {
switch {
case operation.Context != nil: //nolint:staticcheck // kept for backward compatibility
return operation.Context
case r.Context != nil:
return r.Context
default:
return context.Background()
}
}
func (r *Runtime) pickScheme(schemes []string) string {
if v := r.selectScheme(r.schemes); v != "" {
return v
@@ -518,16 +343,121 @@ func transportOrDefault(left, right http.RoundTripper) http.RoundTripper {
return left
}
// takes a client operation and creates equivalent http.Request.
func (r *Runtime) createHttpRequest(operation *runtime.ClientOperation) (*request, *http.Request, error) { //nolint:revive
// ensureClient lazily initializes r.client from r.Transport and r.Jar
// on first use. Safe under concurrent calls via sync.Once.
func (r *Runtime) ensureClient() {
r.clientOnce.Do(func() {
r.client = &http.Client{
Transport: r.Transport,
Jar: r.Jar,
}
})
}
// pickClient returns the http.Client to use for this operation: the
// per-operation override if set, else the runtime's shared client.
func (r *Runtime) pickClient(operation *runtime.ClientOperation) *http.Client {
if operation.Client != nil {
return operation.Client
}
return r.client
}
// dumpRequest writes the outgoing request to the debug logger when
// r.Debug is enabled. No-op otherwise. Returns the dump error so the
// caller can decide whether to abort the submit.
func (r *Runtime) dumpRequest(req *http.Request) error {
if !r.Debug {
return nil
}
b, err := httputil.DumpRequestOut(req, true)
if err != nil {
return err
}
r.logger.Debugf("%s\n", string(b))
return nil
}
// dumpResponse writes the incoming response to the debug logger when
// r.Debug is enabled. The body is omitted for runtime.DefaultMime
// (binary blob). No-op otherwise.
func (r *Runtime) dumpResponse(res *http.Response, ct string) error {
if !r.Debug {
return nil
}
printBody := ct != runtime.DefaultMime // Spare the terminal from a binary blob.
b, err := httputil.DumpResponse(res, printBody)
if err != nil {
return err
}
r.logger.Debugf("%s\n", string(b))
return nil
}
// resolveConsumer parses ct and returns the registered Consumer for
// that media type. Lookup is alias-aware (RFC 9512 §2.1 — yaml
// aliases) and, when [Runtime.MatchSuffix] is true, also tolerates
// RFC 6839 structured-syntax suffix media types (+json, +xml, +yaml).
// Falls back to the "*/*" entry if no match found.
func (r *Runtime) resolveConsumer(ct string) (runtime.Consumer, error) {
if _, _, err := mime.ParseMediaType(ct); err != nil {
return nil, fmt.Errorf("parse content type: %w", err)
}
if cons, ok := mediatype.Lookup(r.Consumers, ct, r.matchOpts()...); ok {
return cons, nil
}
if cons, ok := r.Consumers["*/*"]; ok {
return cons, nil
}
// scream about not knowing what to do
return nil, fmt.Errorf("no consumer: %q", ct)
}
// matchOpts builds the mediatype.MatchOption slice for codec
// lookups on the Runtime, currently just the AllowSuffix opt-in.
func (r *Runtime) matchOpts() []mediatype.MatchOption {
if !r.MatchSuffix {
return nil
}
return []mediatype.MatchOption{mediatype.AllowSuffix()}
}
// createHTTPRequestContext is the context-aware builder of a [http.Request].
//
// The returned [http.Request] carries a context derived from parentCtx that
// honors the per-request timeout set during WriteToRequest. Callers must
// invoke cancel once the response is fully read.
func (r *Runtime) createHTTPRequestContext(parentCtx context.Context, operation *runtime.ClientOperation) (*http.Request, context.CancelFunc, error) {
req, cmt, auth, err := r.prepareRequest(operation)
if err != nil {
return nil, nil, err
}
httpReq, cancel, err := req.BuildHTTPContext(parentCtx, cmt, r.BasePath, r.Producers, r.Formats, auth)
if err != nil {
return nil, nil, err
}
r.applyHostScheme(httpReq, operation)
return httpReq, cancel, nil
}
// prepareRequest performs the operation-to-request setup that is
// independent of how the http.Request is finally assembled: parameters,
// headers, default authentication, and consumes-media-type selection.
func (r *Runtime) prepareRequest(operation *runtime.ClientOperation) (*request.Request, string, runtime.ClientAuthInfoWriter, error) {
params, _, auth := operation.Params, operation.Reader, operation.AuthInfo
request := newRequest(operation.Method, operation.PathPattern, params)
req := request.New(operation.Method, operation.PathPattern, params)
_ = req.SetTimeout(DefaultTimeout) // the timeout may be overridden by ClientRequestWriter
req.SetConsumes(operation.ConsumesMediaTypes)
accept := make([]string, 0, len(operation.ProducesMediaTypes))
accept = append(accept, operation.ProducesMediaTypes...)
if err := request.SetHeaderParam(runtime.HeaderAccept, accept...); err != nil {
return nil, nil, err
if err := req.SetHeaderParam(runtime.HeaderAccept, accept...); err != nil {
return nil, "", nil, err
}
if auth == nil && r.DefaultAuthentication != nil {
@@ -538,39 +468,75 @@ func (r *Runtime) createHttpRequest(operation *runtime.ClientOperation) (*reques
return r.DefaultAuthentication.AuthenticateRequest(req, reg)
})
}
// if auth != nil {
// if err := auth.AuthenticateRequest(request, r.Formats); err != nil {
// return nil, err
// }
//}
// Enhancement proposal: https://github.com/go-openapi/runtime/issues/386
cmt := r.DefaultMediaType
for _, mediaType := range operation.ConsumesMediaTypes {
// Pick first non-empty media type
if mediaType != "" {
cmt = mediaType
break
cmt := pickConsumesMediaType(operation.ConsumesMediaTypes, r.Producers, r.DefaultMediaType, r.matchOpts()...)
if _, ok := mediatype.Lookup(r.Producers, cmt, r.matchOpts()...); !ok && cmt != runtime.MultipartFormMime && cmt != runtime.URLencodedFormMime {
return nil, "", nil, fmt.Errorf("none of producers: %v registered. try %s", r.Producers, cmt)
}
return req, cmt, auth, nil
}
// applyHostScheme stamps the runtime's host and the operation-selected
// scheme onto the freshly built http.Request.
func (r *Runtime) applyHostScheme(httpReq *http.Request, operation *runtime.ClientOperation) {
httpReq.URL.Scheme = r.pickScheme(operation.Schemes)
httpReq.URL.Host = r.Host
httpReq.Host = r.Host
}
// pickConsumesMediaType selects which Content-Type the client will send.
//
// Selection rules, in priority order:
//
// 1. multipart/form-data if any consumes entry advertises it (it streams
// and preserves per-file Content-Type, regardless of codegen ordering;
// resolves issue #286);
// 2. the first non-empty entry whose mime is either structural
// (multipart/form-data or application/x-www-form-urlencoded — these
// do not need a producer in the map) or has a producer registered in
// producers — this lets the client gracefully skip unregistered
// spec entries instead of erroring at the gate that follows;
// 3. the first non-empty entry overall (preserves the historical error
// path: the gate at the call site reports "none of producers" with
// the unregistered mime, so the diagnostic is unchanged when nothing
// in consumes is registered);
// 4. def, if consumes is empty or all empty strings.
//
// Step 2 closes part of issues #32 and #386: an operation declaring
// `consumes: [application/x-vendor, application/json]` with no vendor
// producer registered now silently uses JSON instead of erroring.
func pickConsumesMediaType(consumes []string, producers map[string]runtime.Producer, def string, opts ...mediatype.MatchOption) string {
for _, mt := range consumes {
if strings.EqualFold(mt, runtime.MultipartFormMime) {
return mt
}
}
if _, ok := r.Producers[cmt]; !ok && cmt != runtime.MultipartFormMime && cmt != runtime.URLencodedFormMime {
return nil, nil, fmt.Errorf("none of producers: %v registered. try %s", r.Producers, cmt)
var firstNonEmpty string
for _, mt := range consumes {
if mt == "" {
continue
}
if firstNonEmpty == "" {
firstNonEmpty = mt
}
if isStructuralMime(mt) {
return mt
}
if _, ok := mediatype.Lookup(producers, mt, opts...); ok {
return mt
}
}
req, err := request.buildHTTP(cmt, r.BasePath, r.Producers, r.Formats, auth)
if err != nil {
return nil, nil, err
if firstNonEmpty != "" {
return firstNonEmpty
}
req.URL.Scheme = r.pickScheme(operation.Schemes)
req.URL.Host = r.Host
req.Host = r.Host
return request, req, nil
return def
}
func basePool(pool *x509.CertPool) *x509.CertPool {
if pool == nil {
return x509.NewCertPool()
}
return pool
// isStructuralMime reports whether mt is a media type whose body shape
// is owned by the runtime (multipart envelope, urlencoded form). These
// do not require an entry in the producers map.
func isStructuralMime(mt string) bool {
return strings.EqualFold(mt, runtime.MultipartFormMime) ||
strings.EqualFold(mt, runtime.URLencodedFormMime)
}
+197
View File
@@ -0,0 +1,197 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package client
import (
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"os"
)
// TLSClientOptions to configure client authentication with mutual TLS.
type TLSClientOptions struct {
// Certificate is the path to a PEM-encoded certificate to be used for
// client authentication. If set then Key must also be set.
Certificate string
// LoadedCertificate is the certificate to be used for client authentication.
// This field is ignored if Certificate is set. If this field is set, LoadedKey
// is also required.
LoadedCertificate *x509.Certificate
// Key is the path to an unencrypted PEM-encoded private key for client
// authentication. This field is required if Certificate is set.
Key string
// LoadedKey is the key for client authentication. This field is required if
// LoadedCertificate is set.
LoadedKey crypto.PrivateKey
// CA is a path to a PEM-encoded certificate that specifies the root certificate
// to use when validating the TLS certificate presented by the server. If this field
// (and LoadedCA) is not set, the system certificate pool is used. This field is ignored if LoadedCA
// is set.
CA string
// LoadedCA specifies the root certificate to use when validating the server's TLS certificate.
// If this field (and CA) is not set, the system certificate pool is used.
LoadedCA *x509.Certificate
// LoadedCAPool specifies a pool of RootCAs to use when validating the server's TLS certificate.
// If set, it will be combined with the other loaded certificates (see LoadedCA and CA).
// If neither LoadedCA or CA is set, the provided pool will override the system
// certificate pool.
//
// The caller must not use the supplied pool after calling TLSClientAuth.
LoadedCAPool *x509.CertPool
// ServerName specifies the hostname to use when verifying the server certificate.
// If this field is set then InsecureSkipVerify will be ignored and treated as
// false.
ServerName string
// InsecureSkipVerify controls whether the certificate chain and hostname presented
// by the server are validated. If true, any certificate is accepted.
InsecureSkipVerify bool
// VerifyPeerCertificate, if not nil, is called after normal
// certificate verification. It receives the raw ASN.1 certificates
// provided by the peer and also any verified chains that normal processing found.
// If it returns a non-nil error, the handshake is aborted and that error results.
//
// If normal verification fails then the handshake will abort before
// considering this callback. If normal verification is disabled by
// setting InsecureSkipVerify then this callback will be considered but
// the verifiedChains argument will always be nil.
VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
// VerifyConnection, if not nil, is called after normal certificate
// verification and after [TLSClientOptions.VerifyPeerCertificate] by either a TLS client or
// server. It receives the [tls.ConnectionState] which may be inspected.
//
// Unlike VerifyPeerCertificate, this callback is invoked on every
// connection, including resumed ones, making it suitable for checks
// that must always apply (e.g. certificate pinning).
//
// If it returns a non-nil error, the handshake is aborted and that error results.
VerifyConnection func(tls.ConnectionState) error
// SessionTicketsDisabled may be set to true to disable session ticket and
// PSK (resumption) support. Note that on clients, session ticket support is
// also disabled if ClientSessionCache is nil.
SessionTicketsDisabled bool
// ClientSessionCache is a cache of ClientSessionState entries for TLS
// session resumption. It is only used by clients.
ClientSessionCache tls.ClientSessionCache
// Prevents callers using unkeyed fields.
_ struct{}
}
// TLSClientAuth creates a [tls.Config] for mutual auth.
func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) {
// create client tls config
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
// load client cert if specified
if opts.Certificate != "" {
cert, err := tls.LoadX509KeyPair(opts.Certificate, opts.Key)
if err != nil {
return nil, fmt.Errorf("tls client cert: %w", err)
}
cfg.Certificates = []tls.Certificate{cert}
} else if opts.LoadedCertificate != nil {
block := pem.Block{Type: "CERTIFICATE", Bytes: opts.LoadedCertificate.Raw}
certPem := pem.EncodeToMemory(&block)
// PKCS#8 covers RSA, ECDSA, Ed25519, X25519 (the key types tls.X509KeyPair
// understands) and pairs with the canonical "PRIVATE KEY" PEM label.
keyBytes, err := x509.MarshalPKCS8PrivateKey(opts.LoadedKey)
if err != nil {
return nil, fmt.Errorf("tls client priv key: %w", err)
}
block = pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}
keyPem := pem.EncodeToMemory(&block)
cert, err := tls.X509KeyPair(certPem, keyPem)
if err != nil {
return nil, fmt.Errorf("tls client cert: %w", err)
}
cfg.Certificates = []tls.Certificate{cert}
}
cfg.InsecureSkipVerify = opts.InsecureSkipVerify
cfg.VerifyPeerCertificate = opts.VerifyPeerCertificate
cfg.VerifyConnection = opts.VerifyConnection
cfg.SessionTicketsDisabled = opts.SessionTicketsDisabled
cfg.ClientSessionCache = opts.ClientSessionCache
// When no CA certificate is provided, default to the system cert pool
// that way when a request is made to a server known by the system trust store,
// the name is still verified
switch {
case opts.LoadedCA != nil:
caCertPool := basePool(opts.LoadedCAPool)
caCertPool.AddCert(opts.LoadedCA)
cfg.RootCAs = caCertPool
case opts.CA != "":
// load ca cert
caCert, err := os.ReadFile(opts.CA)
if err != nil {
return nil, fmt.Errorf("tls client ca: %w", err)
}
caCertPool := basePool(opts.LoadedCAPool)
caCertPool.AppendCertsFromPEM(caCert)
cfg.RootCAs = caCertPool
case opts.LoadedCAPool != nil:
cfg.RootCAs = opts.LoadedCAPool
}
// apply servername override
if opts.ServerName != "" {
cfg.InsecureSkipVerify = false
cfg.ServerName = opts.ServerName
}
return cfg, nil
}
// TLSTransport creates a [http.RoundTripper] for a client transport,suitable for mutual TLS auth.
func TLSTransport(opts TLSClientOptions) (http.RoundTripper, error) {
cfg, err := TLSClientAuth(opts)
if err != nil {
return nil, err
}
return &http.Transport{TLSClientConfig: cfg}, nil
}
// TLSClient creates a [http.Client] for mutual auth.
func TLSClient(opts TLSClientOptions) (*http.Client, error) {
transport, err := TLSTransport(opts)
if err != nil {
return nil, err
}
return &http.Client{Transport: transport}, nil
}
// basePool returns pool if non-nil; otherwise it returns a new empty cert pool.
//
// Clones the pool provided up front by the caller.
func basePool(pool *x509.CertPool) *x509.CertPool {
if pool == nil {
return x509.NewCertPool()
}
return pool.Clone()
}
+21 -3
View File
@@ -19,12 +19,30 @@ type ClientOperation struct {
AuthInfo ClientAuthInfoWriter
Params ClientRequestWriter
Reader ClientResponseReader
Context context.Context //nolint:containedctx // we precisely want this type to contain the request context
Client *http.Client
// Deprecated: prefer [ContextualTransport.SubmitContext] to pass the request context explicitly.
Context context.Context //nolint:containedctx // we precisely want this type to contain the request context
Client *http.Client
}
// A ClientTransport implementor knows how to submit Request objects to some destination.
type ClientTransport interface {
// Submit(string, RequestWriter, ResponseReader, AuthInfoWriter) (interface{}, error)
// Submit the operation and return the deserialized response or an error.
Submit(*ClientOperation) (any, error)
}
// ContextualTransport extends [ClientTransport] with an explicit
// context-aware submission method.
//
// Wrappers such as the OpenTelemetry transport type-assert to this
// interface so they can forward an explicit context to the underlying
// transport without setting the cached [ClientOperation.Context] field.
//
// In v2, SubmitContext will be folded into [ClientTransport] itself
// and the cached [ClientOperation.Context] field removed; this interface
// is the v0.x bridge.
type ContextualTransport interface {
ClientTransport
// SubmitContext submits the operation using ctx as the request context.
SubmitContext(ctx context.Context, operation *ClientOperation) (any, error)
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (o *APIError) Error() string {
if err, ok := o.Response.(error); ok {
resp = []byte("'" + sanitizer.Replace(err.Error()) + "'")
} else {
resp, _ = json.Marshal(o.Response)
resp, _ = json.Marshal(o.Response) //nolint:errchkjson // error swallowed as this is our last best effort attempt
}
return fmt.Sprintf("%s (status %d): %s", o.OperationName, o.Code, resp)
+6 -2
View File
@@ -21,8 +21,12 @@ const (
DefaultMime = "application/octet-stream"
// JSONMime the json mime type.
JSONMime = "application/json"
// YAMLMime the [yaml] mime type.
YAMLMime = "application/x-yaml"
// YAMLMime the [yaml] mime type. Set to the canonical RFC 9512
// name (application/yaml). Legacy forms application/x-yaml,
// text/yaml, and text/x-yaml — per RFC 9512 §2.1 "Deprecated
// alias names for this type" — resolve to the same codec via
// the mediatype alias bridge.
YAMLMime = "application/yaml"
// XMLMime the [xml] mime type.
XMLMime = "application/xml"
// TextMime the text mime type.
+9 -9
View File
@@ -100,7 +100,7 @@ func CSVConsumer(opts ...CSVOpt) Consumer {
default:
// support *[][]string, *[]byte, *string
if ptr := reflect.TypeOf(data); ptr.Kind() != reflect.Ptr {
if ptr := reflect.TypeOf(data); ptr.Kind() != reflect.Pointer {
return errors.New("destination must be a pointer")
}
@@ -159,14 +159,14 @@ func CSVConsumer(opts ...CSVOpt) Consumer {
//
// Supported input underlying types and interfaces, prioritized in this order:
//
// - *[csv.Reader]
// - [CSVReader] (reader options are ignored)
// - [io.Reader]
// - [io.WriterTo]
// - [encoding.BinaryMarshaler]
// - [][]string
// - []byte
// - string
// - *[csv.Reader]
// - [CSVReader] (reader options are ignored)
// - [io.Reader]
// - [io.WriterTo]
// - [encoding.BinaryMarshaler]
// - [][]string
// - []byte
// - string
//
// The producer prioritizes situations where buffering the input is not required.
func CSVProducer(opts ...CSVOpt) Producer {
+6
View File
@@ -5,4 +5,10 @@ package runtime
import "github.com/go-openapi/swag/fileutils"
// File represents an uploaded file. Re-exported from
// [fileutils.File] for backwards compatibility.
//
// See [BindForm] (in form.go) for the orchestrator that parses
// multipart / urlencoded request bodies and binds declared file
// fields onto handler-side targets.
type File = fileutils.File
+355
View File
@@ -0,0 +1,355 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package runtime
import (
stderrors "errors"
"fmt"
"mime/multipart"
"net/http"
"strings"
"github.com/go-openapi/errors"
)
// DefaultMaxUploadFilenameLength is the default cap applied to
// FileHeader.Filename for each declared file when [BindForm] is invoked
// without an explicit [BindFormMaxFilenameLen] option.
//
// Multipart headers are allocated per part; an attacker submitting
// multi-MB filenames inflates the parser's memory footprint. 1 KiB
// matches the IETF guidance for sane filename length and is enough
// for realistic uploads.
const DefaultMaxUploadFilenameLength = 1024
// DefaultMaxUploadBodySize limits the size of the body to upload forms to 32MB.
//
// Use an explicit [BindFormMaxBody] option to change this limit.
const DefaultMaxUploadBodySize = int64(32) << 20
// filenamePreviewLen caps the byte length of the FileHeader.Filename
// preview embedded as the ParseError.Value field when the helper
// rejects a too-long filename.
const filenamePreviewLen = 32
// ValidateFilenameLength enforces the FileHeader.Filename length cap
// that [BindForm] applies via [BindFormFile] declarations. Untyped
// binder paths that fetch the file via [http.Request.FormFile]
// directly (rather than declaring the file through [BindFormFile]) call
// this to opt into the same protection.
//
// Returns nil if filename length is within maxLen or maxLen <= 0.
// Otherwise returns a [*errors.ParseError] suitable for direct return
// from a parameter binder. The error embeds a truncated preview of
// the offending filename to keep the error message bounded.
func ValidateFilenameLength(paramName, paramIn, filename string, maxLen int) error {
if maxLen <= 0 || len(filename) <= maxLen {
return nil
}
preview := filename[:min(len(filename), filenamePreviewLen)]
return errors.NewParseError(paramName, paramIn, preview,
fmt.Errorf("filename length %d exceeds limit %d", len(filename), maxLen))
}
// FileBinder is the per-file callback invoked by [BindForm] when a
// declared file field is present.
//
// The callback is responsible for BOTH validating the file (size, MIME, etc.) AND assigning the bound
// file to its destination — typically using:
//
// o.FieldName = &runtime.File{Data: file, Header: header}
//
// Returning a non-nil error surfaces the error in [BindForm]'s per-field
// accumulator. Errors from the binder flow through verbatim — the
// binder is expected to produce HTTP-aware errors (e.g.
// [errors.ExceedsMaximum] from go-openapi/validate).
type FileBinder func(file multipart.File, header *multipart.FileHeader) error
// BindOption configures [BindForm]. The variadic style keeps simple
// call sites simple and lets new knobs (security caps, additional
// behaviour) be added without breaking the signature.
type BindOption func(*bindConfig)
type bindConfig struct {
maxParseMemory int64
maxBody int64
maxFiles int
maxFilenameLen int
files []formFileSpec
}
type formFileSpec struct {
name string
required bool
bind FileBinder
}
// BindFormMaxParseMemory caps the in-memory portion of a multipart
// body. Bytes beyond this are spilled to temporary files on disk by
// the stdlib parser. 0 (the default) defers to the stdlib's 32 MB.
//
// This option does NOT cap total body bytes — see [BindFormMaxBody]
// for that. The default body cap ([DefaultMaxUploadBodySize] = 32 MB)
// is applied even when this option is not supplied, so out of the box
// [BindForm] is bounded; callers with stricter or looser requirements
// adjust via [BindFormMaxBody].
func BindFormMaxParseMemory(n int64) BindOption {
return func(c *bindConfig) { c.maxParseMemory = n }
}
// BindFormMaxBody caps the size of the body read from a http form before parsing.
//
// The limit is set to 32MB by default. This default limit is applied for any n=0.
//
// The limit is disabled for n<0, assuming the caller has already capped the body size upstream.
func BindFormMaxBody(n int64) BindOption {
return func(c *bindConfig) { c.maxBody = n }
}
// BindFormMaxFiles rejects parses where the total number of file
// parts across all field names exceeds n. 0 (the default) means no
// cap. Exceeding the cap is a fatal error — [BindForm] returns
// fatal=true and no per-file binders run.
func BindFormMaxFiles(n int) BindOption {
return func(c *bindConfig) { c.maxFiles = n }
}
// BindFormMaxFilenameLen rejects per-file headers whose Filename
// length exceeds n. 0 means no cap; the default applied when this
// option is not supplied is [DefaultMaxUploadFilenameLength]. The
// cap is a per-field bind error (non-fatal); other declared files
// still run.
func BindFormMaxFilenameLen(n int) BindOption {
return func(c *bindConfig) { c.maxFilenameLen = n }
}
// BindFormFile declares a file field to bind under the given form
// name. If required is true and the field is absent, [BindForm]
// produces the per-field error.
//
// errors.NewParseError(name, "formData", "", http.ErrMissingFile)
//
// If required is false, absence is silent (no error, no bind).
//
// The bind callback runs only when the field is present. It is the
// site where both validation and assignment happen — see [FileBinder].
//
// FileHeader.Filename is attacker-controlled text; the binder MUST
// NOT use it directly as a filesystem path. The helper does not
// touch the filesystem.
func BindFormFile(name string, required bool, bind FileBinder) BindOption {
return func(c *bindConfig) {
c.files = append(c.files, formFileSpec{
name: name,
required: required,
bind: bind,
})
}
}
// BindForm parses r as multipart/form-data, falling back to
// application/x-www-form-urlencoded when the request is not
// multipart. On success, r.MultipartForm and r.PostForm are populated;
// the caller can read non-file form values via [Values](r.Form) after
// the call returns.
//
// All errors produced by BindForm itself (parse failure, missing
// required field, cap exceeded) are [*errors.ParseError] values built
// via [errors.NewParseError], matching the untyped
// middleware/parameter.go path. Errors returned by per-file binders
// flow through verbatim — binders own their HTTP-aware error shape.
//
// Per-file binders declared via [BindFormFile] run in declaration
// order after a successful parse. Their errors are accumulated and
// returned wrapped in [errors.CompositeValidationError]; the caller
// typically appends the returned err to its own []error and continues
// with non-file parameter binding.
//
// Return semantics:
//
// - fatal=true, err!=nil: parse failure or a hard cap (e.g.
// [BindFormMaxFiles]) was exceeded. No per-file binders ran; the
// caller MUST return err immediately.
// - fatal=false, err!=nil: one or more per-file binders produced
// errors. The form parsed successfully; r.Form is populated. The
// caller appends err to its accumulator and continues.
// - fatal=false, err==nil: full success.
//
// fatal==true implies err!=nil.
//
// Defaults applied out of the box:
//
// - Total body bytes capped at [DefaultMaxUploadBodySize] (32 MB)
// via [http.MaxBytesReader]. Adjust with [BindFormMaxBody]
// (negative n disables, when the caller has already capped the
// body upstream).
// - FileHeader.Filename length capped at
// [DefaultMaxUploadFilenameLength]. Adjust with
// [BindFormMaxFilenameLen].
//
// Caller responsibilities the helper does NOT cover:
//
// - Set [http.Server.ReadTimeout] / [http.Server.IdleTimeout] to defend
// against slow-read attacks.
// - Decompress Content-Encoding: gzip request bodies upstream if
// the API accepts them, using a size-limited reader.
// - Treat FileHeader.Filename as untrusted user input; never use
// it directly as a filesystem path.
func BindForm(r *http.Request, opts ...BindOption) (fatal bool, err error) {
cfg := bindConfig{
maxFilenameLen: DefaultMaxUploadFilenameLength,
}
for _, opt := range opts {
opt(&cfg)
}
if perr := parseFormBody(r, cfg.maxParseMemory, cfg.maxBody); perr != nil {
// Body-cap hit gets the 413 status; everything else maps to a
// 400 ParseError. parseFormBody returns the raw stdlib error
// in both cases — the HTTP-aware wrapping happens here.
var maxBytesErr *http.MaxBytesError
if stderrors.As(perr, &maxBytesErr) {
return true, errors.New(http.StatusRequestEntityTooLarge, "formData: %v", perr)
}
return true, errors.NewParseError("body", "formData", "", perr)
}
if cfg.maxFiles > 0 {
if got := countFileParts(r); got > cfg.maxFiles {
return true, errors.NewParseError("body", "formData", "",
fmt.Errorf("multipart form contains %d file parts, exceeds limit %d", got, cfg.maxFiles))
}
}
var bindErrs []error
for _, spec := range cfg.files {
if e := bindFormFile(r, spec, cfg.maxFilenameLen); e != nil {
bindErrs = append(bindErrs, e)
}
}
if len(bindErrs) > 0 {
return false, errors.CompositeValidationError(bindErrs...)
}
return false, nil
}
// parseFormBody parses the request body. Content-Type drives the
// parser: multipart/form-data → r.ParseMultipartForm, everything else
// → r.ParseForm (stdlib's parsePostForm only actually reads the body
// when Content-Type is application/x-www-form-urlencoded, so calling
// ParseForm is safe for unrecognised types).
//
// Caveat: ParseMultipartForm calls ParseForm internally and discards its error
// when the body turns out not to be multipart, returning ErrNotMultipart instead
// — the subsequent retry then short-circuits because r.PostForm is already
// set. Content-type-based routing avoids the lossy detour.
//
// Returns the raw stdlib error on failure; the caller (BindForm)
// handles HTTP-aware wrapping (413 for MaxBytesError, 400 ParseError
// otherwise).
//
// maxMemory == 0 falls through to the stdlib default (32 MB).
// maxBody == 0 defaults to DefaultMaxUploadBodySize; maxBody < 0
// disables the body cap (caller has capped upstream).
func parseFormBody(r *http.Request, maxMemory, maxBody int64) error {
if r.Body != nil && maxBody >= 0 {
if maxBody == 0 {
maxBody = DefaultMaxUploadBodySize
}
r.Body = http.MaxBytesReader(nil, r.Body, maxBody)
}
mt, _, _ := ContentType(r.Header)
if mt == MultipartFormMime {
//nolint:gosec // G120: false positive -- see below
// gosec doesn't track the Body.
// See https://github.com/securego/gosec/blob/de65614d10a6b84029e3e1215567b8ce7e490f23/testutils/g120_samples.go#L57
return r.ParseMultipartForm(maxMemory)
}
return r.ParseForm()
}
func countFileParts(r *http.Request) int {
if r.MultipartForm == nil {
return 0
}
var n int
for _, fhs := range r.MultipartForm.File {
n += len(fhs)
}
return n
}
// FormFile resolves a file field from a parsed form body, transparently
// handling both content types accepted for `type: file` parameters by
// the OpenAPI 2.0 spec:
//
// - multipart/form-data — delegates to [http.Request.FormFile].
// - application/x-www-form-urlencoded — looks up the field in
// r.PostForm and synthesizes a [multipart.File] backed by the
// value bytes plus a [multipart.FileHeader] with Filename equal
// to the field name and Size set to the byte length.
//
// Returns [http.ErrMissingFile] when the field is absent under either
// content type. Callers must have parsed the body upstream (e.g. via
// [BindForm] or [http.Request.ParseForm]) before reading from the
// urlencoded path — [http.Request.FormFile] takes care of parsing on
// the multipart path.
//
// Presence is the only criterion for binding a urlencoded file: an
// empty value (e.g. `file=`) is bound as a zero-byte file.
func FormFile(r *http.Request, name string) (multipart.File, *multipart.FileHeader, error) {
file, header, err := r.FormFile(name)
if err == nil {
return file, header, nil
}
if !stderrors.Is(err, http.ErrNotMultipart) {
return nil, nil, err
}
values, present := r.PostForm[name]
if !present {
return nil, nil, http.ErrMissingFile
}
value := values[0]
return urlencodedFile{Reader: strings.NewReader(value)},
&multipart.FileHeader{Filename: name, Size: int64(len(value))},
nil
}
// urlencodedFile adapts a urlencoded form value (already buffered in
// memory by [http.Request.ParseForm]) to the [multipart.File]
// interface. The embedded [strings.Reader] supplies Read/ReadAt/Seek;
// Close is a no-op since there is no resource to release.
type urlencodedFile struct {
*strings.Reader
}
func (urlencodedFile) Close() error { return nil }
func bindFormFile(r *http.Request, spec formFileSpec, maxFilenameLen int) error {
file, header, err := FormFile(r, spec.name)
if err != nil {
if stderrors.Is(err, http.ErrMissingFile) {
if spec.required {
return errors.New(http.StatusBadRequest, "formData: %v", http.ErrMissingFile)
}
return nil
}
return errors.NewParseError(spec.name, "formData", "", err)
}
if err := ValidateFilenameLength(spec.name, "formData", header.Filename, maxFilenameLen); err != nil {
return err
}
if spec.bind == nil {
return nil
}
return spec.bind(file, header)
}
+3 -1
View File
@@ -1,6 +1,8 @@
use (
.
./client-middleware/opentracing
./docs/examples
./server-middleware
)
go 1.24.0
go 1.25.0
-109
View File
@@ -1,109 +0,0 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+22
View File
@@ -99,3 +99,25 @@ type Validatable interface {
type ContextValidatable interface {
ContextValidate(context.Context, strfmt.Registry) error
}
// ContentTyper is implemented by values that declare their own MIME
// content type. The client runtime consults it in two places:
//
// - on a body payload set via [SetBodyParam]: when the payload is a
// stream (io.Reader, io.ReadCloser) and ContentType returns a
// non-empty value, that value becomes the wire Content-Type
// header instead of the operation's picked consumes entry.
//
// - on individual file values inside a multipart upload: their per-
// part Content-Type header is taken from ContentType() rather
// than sniffed via http.DetectContentType.
//
// An empty string return is treated as "no opinion" and the runtime
// falls back to its default selection. Values that have no content
// type to declare may simply not implement the interface.
//
// See docs/MEDIA_TYPES.md for the full client-side selection
// algorithm.
type ContentTyper interface {
ContentType() string
}
+418 -316
View File
@@ -5,10 +5,9 @@ package middleware
import (
stdContext "context"
stderrors "errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"sync"
@@ -17,19 +16,21 @@ import (
"github.com/go-openapi/loads"
"github.com/go-openapi/spec"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag/typeutils"
"github.com/go-openapi/runtime"
"github.com/go-openapi/runtime/logger"
"github.com/go-openapi/runtime/middleware/untyped"
"github.com/go-openapi/runtime/security"
"github.com/go-openapi/runtime/server-middleware/docui"
"github.com/go-openapi/runtime/server-middleware/mediatype"
"github.com/go-openapi/runtime/server-middleware/negotiate"
)
// Debug when true turns on verbose logging.
var Debug = logger.DebugEnabled()
// Logger is the standard library logger used for printing debug messages.
//
// (Note: The correct spelling is "library", not "libra". "Libra" is a zodiac sign/constellation and wouldn't make sense in this context.)
var Logger logger.Logger = logger.StandardLogger{}
func debugLogfFunc(lg logger.Logger) func(string, ...any) {
@@ -75,121 +76,13 @@ func (fn ResponderFunc) WriteResponse(rw http.ResponseWriter, pr runtime.Produce
// used throughout to store request context with the standard context attached
// to the [http.Request].
type Context struct {
spec *loads.Document
analyzer *analysis.Spec
api RoutableAPI
router Router
debugLogf func(string, ...any) // a logging function to debug context and all components using it
}
type routableUntypedAPI struct {
api *untyped.API
hlock *sync.Mutex
handlers map[string]map[string]http.Handler
defaultConsumes string
defaultProduces string
}
func newRoutableUntypedAPI(spec *loads.Document, api *untyped.API, context *Context) *routableUntypedAPI {
var handlers map[string]map[string]http.Handler
if spec == nil || api == nil {
return nil
}
analyzer := analysis.New(spec.Spec())
for method, hls := range analyzer.Operations() {
um := strings.ToUpper(method)
for path, op := range hls {
schemes := analyzer.SecurityRequirementsFor(op)
if oh, ok := api.OperationHandlerFor(method, path); ok {
if handlers == nil {
handlers = make(map[string]map[string]http.Handler)
}
if b, ok := handlers[um]; !ok || b == nil {
handlers[um] = make(map[string]http.Handler)
}
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// lookup route info in the context
route, rCtx, _ := context.RouteInfo(r)
if rCtx != nil {
r = rCtx
}
// bind and validate the request using reflection
var bound any
var validation error
bound, r, validation = context.BindAndValidate(r, route)
if validation != nil {
context.Respond(w, r, route.Produces, route, validation)
return
}
// actually handle the request
result, err := oh.Handle(bound)
if err != nil {
// respond with failure
context.Respond(w, r, route.Produces, route, err)
return
}
// respond with success
context.Respond(w, r, route.Produces, route, result)
})
if len(schemes) > 0 {
handler = newSecureAPI(context, handler)
}
handlers[um][path] = handler
}
}
}
return &routableUntypedAPI{
api: api,
hlock: new(sync.Mutex),
handlers: handlers,
defaultProduces: api.DefaultProduces,
defaultConsumes: api.DefaultConsumes,
}
}
func (r *routableUntypedAPI) HandlerFor(method, path string) (http.Handler, bool) {
r.hlock.Lock()
paths, ok := r.handlers[strings.ToUpper(method)]
if !ok {
r.hlock.Unlock()
return nil, false
}
handler, ok := paths[path]
r.hlock.Unlock()
return handler, ok
}
func (r *routableUntypedAPI) ServeErrorFor(_ string) func(http.ResponseWriter, *http.Request, error) {
return r.api.ServeError
}
func (r *routableUntypedAPI) ConsumersFor(mediaTypes []string) map[string]runtime.Consumer {
return r.api.ConsumersFor(mediaTypes)
}
func (r *routableUntypedAPI) ProducersFor(mediaTypes []string) map[string]runtime.Producer {
return r.api.ProducersFor(mediaTypes)
}
func (r *routableUntypedAPI) AuthenticatorsFor(schemes map[string]spec.SecurityScheme) map[string]runtime.Authenticator {
return r.api.AuthenticatorsFor(schemes)
}
func (r *routableUntypedAPI) Authorizer() runtime.Authorizer {
return r.api.Authorizer()
}
func (r *routableUntypedAPI) Formats() strfmt.Registry {
return r.api.Formats()
}
func (r *routableUntypedAPI) DefaultProduces() string {
return r.defaultProduces
}
func (r *routableUntypedAPI) DefaultConsumes() string {
return r.defaultConsumes
spec *loads.Document
analyzer *analysis.Spec
api RoutableAPI
router Router
debugLogf func(string, ...any) // a logging function to debug context and all components using it
ignoreParameters bool // see SetIgnoreParameters / WithIgnoreParameters
matchSuffix bool // see SetMatchSuffix / WithMatchSuffix
}
// NewRoutableContext creates a new context for a routable API.
@@ -246,6 +139,156 @@ func Serve(spec *loads.Document, api *untyped.API) http.Handler {
return ServeWithBuilder(spec, api, PassthroughBuilder)
}
// SetIgnoreParameters toggles the legacy parameter-stripping behaviour for
// Accept negotiation server-wide. When set, every internal call to
// [NegotiateContentType] from this Context applies [WithIgnoreParameters].
//
// Returns the receiver for fluent configuration:
//
// ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true)
//
// See [WithIgnoreParameters] for the rationale and an example.
func (c *Context) SetIgnoreParameters(ignore bool) *Context {
c.ignoreParameters = ignore
return c
}
// SetMatchSuffix toggles RFC 6839 structured-syntax suffix tolerance
// server-wide. When enabled, both Accept negotiation and codec lookup
// fall back through the suffix base for the recognised suffixes
// (+json, +xml, +yaml) — so an operation declaring
// consumes: [application/json] also accepts request bodies sent with
// Content-Type: application/vnd.api+json (or any other +json variant).
//
// Default: strict (false). Use only when interoperating with clients
// that do not strictly abide by the spec.
//
// Returns the receiver for fluent configuration:
//
// ctx := middleware.NewContext(spec, api, nil).SetMatchSuffix(true)
//
// See [negotiate.WithMatchSuffix] for the per-call form and rationale.
func (c *Context) SetMatchSuffix(enable bool) *Context {
c.matchSuffix = enable
return c
}
type routableUntypedAPI struct {
api *untyped.API
hlock *sync.Mutex
handlers map[string]map[string]http.Handler
defaultConsumes string
defaultProduces string
}
func newRoutableUntypedAPI(spec *loads.Document, api *untyped.API, context *Context) *routableUntypedAPI {
var handlers map[string]map[string]http.Handler
if spec == nil || api == nil {
return nil
}
analyzer := analysis.New(spec.Spec())
for method, hls := range analyzer.Operations() {
um := strings.ToUpper(method)
for path, op := range hls {
schemes := analyzer.SecurityRequirementsFor(op)
oh, ok := api.OperationHandlerFor(method, path)
if !ok {
continue
}
if handlers == nil {
handlers = make(map[string]map[string]http.Handler)
}
if b, ok := handlers[um]; !ok || b == nil {
handlers[um] = make(map[string]http.Handler)
}
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// lookup route info in the context
route, rCtx, _ := context.RouteInfo(r)
if rCtx != nil {
r = rCtx
}
// bind and validate the request using reflection
var bound any
var validation error
bound, r, validation = context.BindAndValidate(r, route)
if validation != nil {
context.Respond(w, r, route.Produces, route, validation)
return
}
// actually handle the request
result, err := oh.Handle(bound)
if err != nil {
// respond with failure
context.Respond(w, r, route.Produces, route, err)
return
}
// respond with success
context.Respond(w, r, route.Produces, route, result)
})
if len(schemes) > 0 {
handler = newSecureAPI(context, handler)
}
handlers[um][path] = handler
}
}
return &routableUntypedAPI{
api: api,
hlock: new(sync.Mutex),
handlers: handlers,
defaultProduces: api.DefaultProduces,
defaultConsumes: api.DefaultConsumes,
}
}
func (r *routableUntypedAPI) HandlerFor(method, path string) (http.Handler, bool) {
r.hlock.Lock()
paths, ok := r.handlers[strings.ToUpper(method)]
if !ok {
r.hlock.Unlock()
return nil, false
}
handler, ok := paths[path]
r.hlock.Unlock()
return handler, ok
}
func (r *routableUntypedAPI) ServeErrorFor(_ string) func(http.ResponseWriter, *http.Request, error) {
return r.api.ServeError
}
func (r *routableUntypedAPI) ConsumersFor(mediaTypes []string) map[string]runtime.Consumer {
return r.api.ConsumersFor(mediaTypes)
}
func (r *routableUntypedAPI) ProducersFor(mediaTypes []string) map[string]runtime.Producer {
return r.api.ProducersFor(mediaTypes)
}
func (r *routableUntypedAPI) AuthenticatorsFor(schemes map[string]spec.SecurityScheme) map[string]runtime.Authenticator {
return r.api.AuthenticatorsFor(schemes)
}
func (r *routableUntypedAPI) Authorizer() runtime.Authorizer {
return r.api.Authorizer()
}
func (r *routableUntypedAPI) Formats() strfmt.Registry {
return r.api.Formats()
}
func (r *routableUntypedAPI) DefaultProduces() string {
return r.defaultProduces
}
func (r *routableUntypedAPI) DefaultConsumes() string {
return r.defaultConsumes
}
// ServeWithBuilder serves the specified spec with the specified api registrations as a [http.Handler] that is decorated
// by the Builder.
func ServeWithBuilder(spec *loads.Document, api *untyped.API, builder Builder) http.Handler {
@@ -319,57 +362,42 @@ func (c *Context) RequiredProduces() []string {
// BindValidRequest binds a params object to a request but only when the request is valid
// if the request is not valid an error will be returned.
func (c *Context) BindValidRequest(request *http.Request, route *MatchedRoute, binder RequestBinder) error {
var res []error
var requestContentType string
// check and validate content type, select consumer
if runtime.HasBody(request) {
ct, _, err := runtime.ContentType(request.Header)
ct, cons, err := c.bindRequestBody(request, route)
if err != nil {
res = append(res, err)
} else {
c.debugLogf("validating content type for %q against [%s]", ct, strings.Join(route.Consumes, ", "))
if err := validateContentType(route.Consumes, ct); err != nil {
res = append(res, err)
}
if len(res) == 0 {
cons, ok := route.Consumers[ct]
if !ok {
res = append(res, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct))
} else {
route.Consumer = cons
requestContentType = ct
}
}
return errors.CompositeValidationError(err)
}
// happy path
requestContentType = ct
route.Consumer = cons
}
// check and validate the response format
if len(res) == 0 {
// if the route does not provide Produces and a default contentType could not be identified
// based on a body, typical for GET and DELETE requests, then default contentType to.
if len(route.Produces) == 0 && requestContentType == "" {
requestContentType = "*/*"
}
// if the route does not provide Produces and a default contentType could not be identified
// based on a body, typical for GET and DELETE requests, then default contentType to.
if len(route.Produces) == 0 && requestContentType == "" {
requestContentType = "*/*"
}
if str := NegotiateContentType(request, route.Produces, requestContentType); str == "" {
res = append(res, errors.InvalidResponseFormat(request.Header.Get(runtime.HeaderAccept), route.Produces))
}
str := negotiate.ContentType(request, route.Produces, requestContentType, c.negotiateOpts()...)
if str == "" {
return errors.CompositeValidationError(
errors.InvalidResponseFormat(request.Header.Get(runtime.HeaderAccept), route.Produces),
)
}
if binder == nil {
return nil
}
// now bind the request with the provided binder
// it's assumed the binder will also validate the request and return an error if the
// request is invalid
if binder != nil && len(res) == 0 {
if err := binder.BindRequest(request, route); err != nil {
return err
}
}
if len(res) > 0 {
return errors.CompositeValidationError(res...)
}
return nil
return binder.BindRequest(request, route)
}
// ContentType gets the parsed value of a content type
@@ -431,7 +459,7 @@ func (c *Context) ResponseFormat(r *http.Request, offers []string) (string, *htt
return v, r
}
format := NegotiateContentType(r, offers, "")
format := negotiate.ContentType(r, offers, "", c.negotiateOpts()...)
if format != "" {
c.debugLogf("[%s %s] set response format %q in context", r.Method, r.URL.Path, format)
r = r.WithContext(stdContext.WithValue(rCtx, ctxResponseFormat, format))
@@ -453,44 +481,6 @@ func (c *Context) ResetAuth(request *http.Request) *http.Request {
return request.WithContext(rctx)
}
// Authorize authorizes the request
// Returns the principal object and a shallow copy of the request when its
// context doesn't contain the principal, otherwise the same request or an error
// (the last) if one of the authenticators returns one or an Unauthenticated error.
func (c *Context) Authorize(request *http.Request, route *MatchedRoute) (any, *http.Request, error) {
if route == nil || !route.HasAuth() {
return nil, nil, nil
}
var rCtx = request.Context()
if v := rCtx.Value(ctxSecurityPrincipal); v != nil {
return v, request, nil
}
applies, usr, err := route.Authenticators.Authenticate(request, route)
if !applies || err != nil || !route.Authenticators.AllowsAnonymous() && usr == nil {
if err != nil {
return nil, nil, err
}
return nil, nil, errors.Unauthenticated("invalid credentials")
}
if route.Authorizer != nil {
if err := route.Authorizer.Authorize(request, usr); err != nil {
if _, ok := err.(errors.Error); ok {
return nil, nil, err
}
return nil, nil, errors.New(http.StatusForbidden, "%v", err)
}
}
rCtx = request.Context()
rCtx = stdContext.WithValue(rCtx, ctxSecurityPrincipal, usr)
rCtx = stdContext.WithValue(rCtx, ctxSecurityScopes, route.Authenticator.AllScopes())
return usr, request.WithContext(rCtx), nil
}
// BindAndValidate binds and validates the request
// Returns the validation map and a shallow copy of the request when its context
// doesn't contain the validation, otherwise it returns the same request or an
@@ -523,91 +513,29 @@ func (c *Context) NotFound(rw http.ResponseWriter, r *http.Request) {
// Respond renders the response after doing some content negotiation.
func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []string, route *MatchedRoute, data any) {
c.debugLogf("responding to %s %s with produces: %v", r.Method, r.URL.Path, produces)
offers := []string{}
for _, mt := range produces {
if mt != c.api.DefaultProduces() {
offers = append(offers, mt)
}
}
// the default producer is last so more specific producers take precedence
offers = append(offers, c.api.DefaultProduces())
c.debugLogf("offers: %v", offers)
offers := c.buildOffers(produces)
var format string
format, r = c.ResponseFormat(r, offers)
rw.Header().Set(runtime.HeaderContentType, format)
if resp, ok := data.(Responder); ok {
producers := route.Producers
// producers contains keys with normalized format, if a format has MIME type parameter such as `text/plain; charset=utf-8`
// then you must provide `text/plain` to get the correct producer. HOWEVER, format here is not normalized.
prod, ok := producers[normalizeOffer(format)]
if !ok {
prods := c.api.ProducersFor(normalizeOffers([]string{c.api.DefaultProduces()}))
pr, ok := prods[c.api.DefaultProduces()]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
prod = pr
}
resp.WriteResponse(rw, prod)
c.respondWithResponder(rw, r, route, resp, format)
return
}
if err, ok := data.(error); ok {
if format == "" {
rw.Header().Set(runtime.HeaderContentType, runtime.JSONMime)
}
if realm := security.FailedBasicAuth(r); realm != "" {
rw.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", realm))
}
if route == nil || route.Operation == nil {
c.api.ServeErrorFor("")(rw, r, err)
return
}
c.api.ServeErrorFor(route.Operation.ID)(rw, r, err)
c.respondWithError(rw, r, produces, route, err, format)
return
}
if route == nil || route.Operation == nil {
rw.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
producers := c.api.ProducersFor(normalizeOffers(offers))
prod, ok := producers[format]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
if err := prod.Produce(rw, data); err != nil {
panic(err) // let the recovery middleware deal with this
}
c.respondWithoutCode(rw, r, data, format, offers)
return
}
if _, code, ok := route.Operation.SuccessResponse(); ok {
rw.WriteHeader(code)
if code == http.StatusNoContent || r.Method == http.MethodHead {
return
}
producers := route.Producers
prod, ok := producers[format]
if !ok {
if !ok {
prods := c.api.ProducersFor(normalizeOffers([]string{c.api.DefaultProduces()}))
pr, ok := prods[c.api.DefaultProduces()]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
prod = pr
}
}
if err := prod.Produce(rw, data); err != nil {
panic(err) // let the recovery middleware deal with this
}
c.respondWithCode(rw, r, route, code, data, format)
return
}
@@ -618,57 +546,76 @@ func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []st
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI ([SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
// A spec UI ([docui.SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with combined [UIOption]).
//
// Deprecated: use [Context.APIHandlerWithUI] with [docui.SwaggerUI] middleware instead.
func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}
specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var swaggerUIOpts SwaggerUIOpts
fromCommonToAnyOptions(uiOpts, &swaggerUIOpts)
return Spec(specPath, c.spec.Raw(), SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...)
return c.APIHandlerWithUI(builder, docui.UseSwaggerUI, c.uiOptionsForHandler(opts)...)
}
// APIHandlerRapiDoc returns a handler to serve the API.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI ([RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
// A spec UI ([docui.RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with combined [UIOption]).
//
// Deprecated: use [Context.APIHandlerWithUI] with [docui.UseRapiDoc] middleware instead.
func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}
specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var rapidocUIOpts RapiDocOpts
fromCommonToAnyOptions(uiOpts, &rapidocUIOpts)
return Spec(specPath, c.spec.Raw(), RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...)
return c.APIHandlerWithUI(builder, docui.UseRapiDoc, c.uiOptionsForHandler(opts)...)
}
// APIHandler returns a handler to serve the API.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI ([Redoc]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with uiOptions).
// A spec UI ([docui.Redoc]) is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with combined [UIOption]).
//
// Notice that you may use [Context.APIHandlerWithUI] to use an alternate UI-serving middleware.
func (c *Context) APIHandler(builder Builder, opts ...UIOption) http.Handler {
return c.APIHandlerWithUI(builder, docui.UseRedoc, c.uiOptionsForHandler(opts)...)
}
// APIHandlerWithUI returns a handler to serve the API with a swagger spec and a UI.
//
// This handler includes a swagger spec, router and the contract defined in the swagger spec.
//
// A spec UI is served at {API base path}/docs and the spec document at /swagger.json
// (these can be modified with combined [UIOption]).
//
// Notice that any function that accepts the [docui.Option] set and returns a valid middleware may be injected here.
//
// [Context.APIHandlerWithUI] extends [Context.APIHandler], and supersedes [Context.APIHandlerRapiDoc] and [Context.APIHandlerSwaggerUI].
func (c *Context) APIHandlerWithUI(builder Builder, uiMiddleware docui.UIMiddleware, opts ...docui.Option) http.Handler {
b := builder
if b == nil {
b = PassthroughBuilder
}
specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts)
var redocOpts RedocOpts
fromCommonToAnyOptions(uiOpts, &redocOpts)
// the UI titles defaults to the title in the spec
const extraOptions = 2
prepend := make([]docui.Option, 0, len(opts)+extraOptions)
var title string
return Spec(specPath, c.spec.Raw(), Redoc(redocOpts, c.RoutesHandler(b)), specOpts...)
sp := c.spec.Spec()
if sp != nil && sp.Info != nil && sp.Info.Title != "" {
title = sp.Info.Title
}
if title != "" {
prepend = append(prepend, docui.WithUITitle(title))
}
prepend = append(prepend, docui.WithUIBasePath(c.BasePath()))
prepend = append(prepend, opts...)
// aligns spec serve path with UI setting to fetch spec document.
return docui.UseSpec(c.spec.Raw(), docui.WithSpecPathFromOptions(prepend...))(
uiMiddleware(prepend...)(
c.RoutesHandler(b),
),
)
}
// RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec.
@@ -680,37 +627,192 @@ func (c *Context) RoutesHandler(builder Builder) http.Handler {
return NewRouter(c, b(NewOperationExecutor(c)))
}
func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []SpecOption) {
var title string
sp := c.spec.Spec()
if sp != nil && sp.Info != nil && sp.Info.Title != "" {
title = sp.Info.Title
// authorizeImpl is the real authentication+authorization body shared
// between the production and dev-only variants of [Context.Authorize].
// See context_skipauth_disabled.go (default build) and
// context_skipauth_enabled.go (the `openapi_unsafe_skipauth` build tag).
//
// The doc on the exported Authorize describes the user-facing
// contract; this function MUST NOT change semantics for the
// production path.
func (c *Context) authorizeImpl(request *http.Request, route *MatchedRoute) (any, *http.Request, error) {
if route == nil || !route.HasAuth() {
return nil, nil, nil
}
// default options (may be overridden)
const baseOptions = 2
optsForContext := make([]UIOption, 0, len(opts)+baseOptions)
optsForContext = append(optsForContext,
WithUIBasePath(c.BasePath()),
WithUITitle(title),
)
optsForContext = append(optsForContext, opts...)
uiOpts := uiOptionsWithDefaults(optsForContext)
// If spec URL is provided, there is a non-default path to serve the spec.
// This makes sure that the UI middleware is aligned with the Spec middleware.
u, _ := url.Parse(uiOpts.SpecURL)
var specPath string
if u != nil {
specPath = u.Path
var rCtx = request.Context()
if v := rCtx.Value(ctxSecurityPrincipal); v != nil {
return v, request, nil
}
pth, doc := path.Split(specPath)
if pth == "." {
pth = ""
applies, usr, err := route.Authenticators.Authenticate(request, route)
if !applies || err != nil || !route.Authenticators.AllowsAnonymous() && typeutils.IsZero(usr) {
if err != nil {
return nil, nil, err
}
return nil, nil, errors.Unauthenticated("invalid credentials")
}
if route.Authorizer != nil {
if err := route.Authorizer.Authorize(request, usr); err != nil {
var apiError errors.Error
if stderrors.As(err, &apiError) {
return nil, nil, err
}
return nil, nil, errors.New(http.StatusForbidden, "%v", err)
}
}
return pth, uiOpts, []SpecOption{WithSpecDocument(doc)}
rCtx = request.Context()
rCtx = stdContext.WithValue(rCtx, ctxSecurityPrincipal, usr)
rCtx = stdContext.WithValue(rCtx, ctxSecurityScopes, route.Authenticator.AllScopes())
return usr, request.WithContext(rCtx), nil
}
func (c *Context) bindRequestBody(request *http.Request, route *MatchedRoute) (string, runtime.Consumer, error) {
ct, _, err := runtime.ContentType(request.Header)
if err != nil {
return "", nil, err
}
c.debugLogf("validating content type for %q against [%s]", ct, strings.Join(route.Consumes, ", "))
if err := validateContentType(route.Consumes, ct); err != nil {
return "", nil, err
}
cons, ok := mediatype.Lookup(route.Consumers, ct, c.matchOpts()...)
if !ok {
return "", nil, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct)
}
return ct, cons, nil
}
func (c *Context) respondWithResponder(rw http.ResponseWriter, r *http.Request, route *MatchedRoute, resp Responder, format string) {
_ = r
producers := route.Producers
// producers contains keys with normalized format, if a format has MIME type parameter such as `text/plain; charset=utf-8`
// then you must provide `text/plain` to get the correct producer. HOWEVER, format here is not normalized.
prod, ok := producers[normalizeOffer(format)]
if !ok {
prods := c.api.ProducersFor(normalizeOffers([]string{c.api.DefaultProduces()}))
pr, ok := prods[c.api.DefaultProduces()]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
prod = pr
}
resp.WriteResponse(rw, prod)
}
func (c *Context) respondWithError(rw http.ResponseWriter, r *http.Request, produces []string, route *MatchedRoute, err error, format string) {
_ = produces
if format == "" {
rw.Header().Set(runtime.HeaderContentType, runtime.JSONMime)
}
if realm := security.FailedBasicAuth(r); realm != "" {
rw.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", realm))
}
if route == nil || route.Operation == nil {
c.api.ServeErrorFor("")(rw, r, err)
return
}
c.api.ServeErrorFor(route.Operation.ID)(rw, r, err)
}
func (c *Context) respondWithoutCode(rw http.ResponseWriter, r *http.Request, data any, format string, offers []string) {
rw.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
producers := c.api.ProducersFor(normalizeOffers(offers))
prod, ok := producers[format]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
if err := prod.Produce(rw, data); err != nil {
panic(err) // let the recovery middleware deal with this
}
}
func (c *Context) buildOffers(produces []string) []string {
offers := make([]string, 0, len(produces)+1)
for _, mt := range produces {
if mt != c.api.DefaultProduces() {
offers = append(offers, mt)
}
}
// the default producer is last so more specific producers take precedence
offers = append(offers, c.api.DefaultProduces())
c.debugLogf("offers: %v", offers)
return offers
}
func (c *Context) respondWithCode(rw http.ResponseWriter, r *http.Request, route *MatchedRoute, code int, data any, format string) {
rw.WriteHeader(code)
if code == http.StatusNoContent || r.Method == http.MethodHead {
return
}
producers := route.Producers
prod, ok := producers[format]
if !ok {
if !ok {
prods := c.api.ProducersFor(normalizeOffers([]string{c.api.DefaultProduces()}))
pr, ok := prods[c.api.DefaultProduces()]
if !ok {
panic(fmt.Errorf("%d: %s", http.StatusInternalServerError, cantFindProducer(format)))
}
prod = pr
}
}
if err := prod.Produce(rw, data); err != nil {
panic(err) // let the recovery middleware deal with this
}
}
// uiOptionsForHandler bridges the deprecated [UIOption] set to the new [docui.Option] set.
func (c Context) uiOptionsForHandler(opts []UIOption) []docui.Option {
uiOpts := uiOptionsWithDefaults(opts)
return uiOpts.toFuncOptions()
}
func (c *Context) negotiateOpts() []negotiate.Option {
var opts []negotiate.Option
if c.ignoreParameters {
opts = append(opts, negotiate.WithIgnoreParameters(true))
}
if c.matchSuffix {
opts = append(opts, negotiate.WithMatchSuffix(true))
}
return opts
}
// matchOpts builds the mediatype.MatchOption slice that the
// codec-lookup and Content-Type validation paths apply server-wide.
// Mirrors negotiateOpts but at the mediatype level (without going
// through the negotiate.Option wrapper).
func (c *Context) matchOpts() []mediatype.MatchOption {
if !c.matchSuffix {
return nil
}
return []mediatype.MatchOption{mediatype.AllowSuffix()}
}
func cantFindProducer(format string) string {
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
//go:build !openapi_unsafe_skipauth
package middleware
import "net/http"
// Authorize authorizes the request.
//
// Returns the principal object and a shallow copy of the request when its
// context doesn't contain the principal, otherwise the same request or an error
// (the last) if one of the authenticators returns one or an Unauthenticated error.
//
// This is the production variant — compiled when the build tag
// `openapi_unsafe_skipauth` is NOT set. There is no skip-auth check
// in this codepath; the field, setter, and storage for the bypass
// flag are entirely absent from the binary. See the alternate
// implementation in context_skipauth_enabled.go for the dev-only
// bypass mechanism.
func (c *Context) Authorize(request *http.Request, route *MatchedRoute) (any, *http.Request, error) {
return c.authorizeImpl(request, route)
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
//go:build openapi_unsafe_skipauth
package middleware
import (
"log"
"net/http"
"sync/atomic"
)
// skipAuthEnabled holds the process-wide skip-auth flag. It only
// exists in binaries built with the `openapi_unsafe_skipauth` tag —
// production binaries (built without the tag) have no field, no
// setter, no storage, and no skip-checking branch in [Context.Authorize].
// Reflection, unsafe-pointer arithmetic, or a debugger cannot flip
// what is not in the binary.
var skipAuthEnabled atomic.Bool
// SetSkipAuth toggles a PROCESS-WIDE bypass of authentication AND
// authorization for every operation served by every Context in the
// running program.
//
// DANGER: this disables ALL authentication and ALL authorization.
// Every request to every secured endpoint runs as if it had been
// authorized with a nil principal. Use ONLY on developer
// workstations during early prototyping (e.g. while
// authentication is not yet wired up).
//
// This function exists only when the build tag
// `openapi_unsafe_skipauth` is set:
//
// go build -tags openapi_unsafe_skipauth ./...
//
// Production CI MUST NOT pass this tag. Calls compile to a symbol
// that does not exist in production binaries.
//
// Calling with true emits a one-line WARNING via the stdlib `log`
// package (stderr by default) so the bypass is visible at startup.
// Calling with false silently disables it.
func SetSkipAuth(skip bool) {
skipAuthEnabled.Store(skip)
if skip {
log.Println("WARNING: go-openapi/runtime: SetSkipAuth(true) — authentication and authorization are bypassed for ALL operations. This MUST NOT run in production.")
}
}
// Authorize is the dev-build variant of the production
// [Context.Authorize] (see context_skipauth_disabled.go for the
// production path). When [SetSkipAuth] has enabled the bypass, this
// returns a nil principal with the original request and no error —
// handlers downstream receive a nil-value principal. Otherwise it
// delegates to the standard authentication+authorization body.
func (c *Context) Authorize(request *http.Request, route *MatchedRoute) (any, *http.Request, error) {
if skipAuthEnabled.Load() {
return nil, request, nil
}
return c.authorizeImpl(request, route)
}
+12 -7
View File
@@ -9,6 +9,7 @@ package denco
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
)
@@ -29,8 +30,8 @@ const (
// PathParamCharacter indicates a RESTCONF path param.
PathParamCharacter = '='
// MaxSize is max size of records and internal slice.
MaxSize = (1 << 22) - 1 //nolint:mnd
// MaxSize is the maximum size of records and internal slice (encoded over 22 bits).
MaxSize = (1 << baseBits) - 1
)
// Router represents a URL router.
@@ -53,9 +54,12 @@ func New() *Router {
}
}
// Lookup returns data and path parameters that associated with path.
// Lookup returns data and path parameters which are associated to the path.
//
// params is a slice of the [Param] that arranged in the order in which parameters appeared.
// e.g. when built routing path is "/path/to/:id/:name" and given path is "/path/to/1/alice". params order is [{"id": "1"}, {"name": "alice"}], not [{"name": "alice"}, {"id": "1"}].
//
// e.g. when built routing path is "/path/to/:id/:name" and given path is "/path/to/1/alice",
// params order is [{"id": "1"}, {"name": "alice"}], not [{"name": "alice"}, {"id": "1"}].
func (rt *Router) Lookup(path string) (data any, params Params, found bool) {
if data, found = rt.static[path]; found {
return data, nil, true
@@ -144,6 +148,7 @@ func newDoubleArray() *doubleArray {
type baseCheck uint32
const (
baseBits = 22
flagsBits = 10
checkBits = 8
)
@@ -157,7 +162,7 @@ func (bc *baseCheck) SetBase(base int) {
}
func (bc baseCheck) Check() byte {
return byte(bc) //nolint:gosec // integer conversion is ok
return byte(bc) //nolint:gosec // integer conversion is ok: we pick the last 8 bits
}
func (bc *baseCheck) SetCheck(check byte) {
@@ -213,8 +218,8 @@ func (da *doubleArray) lookup(path string, params []Param, idx int) (*node, []Pa
}
BACKTRACKING:
for j := len(indices) - 1; j >= 0; j-- {
i, idx := int(indices[j]>>indexOffset), int(indices[j]&indexMask)
for _, j := range slices.Backward(indices) {
i, idx := int(j>>indexOffset), int(j&indexMask)
if da.bc[idx].IsSingleParam() {
nextIdx := nextIndex(da.bc[idx].Base(), ParamCharacter)
if nextIdx >= len(da.bc) {
+14 -12
View File
@@ -9,7 +9,7 @@ import (
"net/http"
)
// Mux represents a multiplexer for HTTP request.
// Mux represents a multiplexer for HTTP requests.
type Mux struct{}
// NewMux returns a new [Mux].
@@ -17,27 +17,27 @@ func NewMux() *Mux {
return &Mux{}
}
// GET is shorthand of [Mux].Handler("GET", path, handler).
// GET is shorthand for [Mux.Handler] ("GET", path, handler).
func (m *Mux) GET(path string, handler HandlerFunc) Handler {
return m.Handler("GET", path, handler)
}
// POST is shorthand of [Mux].Handler("POST", path, handler).
// POST is shorthand for [Mux.Handler] ("POST", path, handler).
func (m *Mux) POST(path string, handler HandlerFunc) Handler {
return m.Handler("POST", path, handler)
}
// PUT is shorthand of [Mux].Handler("PUT", path, handler).
// PUT is shorthand for [Mux.Handler] ("PUT", path, handler).
func (m *Mux) PUT(path string, handler HandlerFunc) Handler {
return m.Handler("PUT", path, handler)
}
// HEAD is shorthand of [Mux].Handler("HEAD", path, handler).
// HEAD is shorthand for [Mux.Handler]("HEAD", path, handler).
func (m *Mux) HEAD(path string, handler HandlerFunc) Handler {
return m.Handler("HEAD", path, handler)
}
// Handler returns a handler for HTTP method.
// Handler returns a [Handler] for a HTTP method.
func (m *Mux) Handler(method, path string, handler HandlerFunc) Handler {
return Handler{
Method: method,
@@ -63,7 +63,7 @@ func (m *Mux) Build(handlers []Handler) (http.Handler, error) {
return mux, nil
}
// Handler represents a handler of HTTP request.
// Handler represents a handler of HTTP requests.
type Handler struct {
// Method is an HTTP method.
Method string
@@ -75,7 +75,7 @@ type Handler struct {
Func HandlerFunc
}
// HandlerFunc is aliased to type of handler function.
// HandlerFunc is an alias to the handler function, similar to [http.HandlerFunc].
type HandlerFunc func(w http.ResponseWriter, r *http.Request, params Params)
type serveMux struct {
@@ -88,7 +88,7 @@ func newServeMux() *serveMux {
}
}
// ServeHTTP implements http.Handler interface.
// ServeHTTP implements the [http.Handler] interface.
func (mux *serveMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler, params := mux.handler(r.Method, r.URL.Path)
handler(w, r, params)
@@ -97,15 +97,17 @@ func (mux *serveMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (mux *serveMux) handler(method, path string) (HandlerFunc, []Param) {
if router, found := mux.routers[method]; found {
if handler, params, found := router.Lookup(path); found {
return handler.(HandlerFunc), params
return handler.(HandlerFunc), params //nolint:forcetypeassert // type is guaranteed when the path is found
}
}
return NotFound, nil
}
// NotFound replies to the request with an HTTP 404 not found error.
// NotFound is called when unknown HTTP method or a handler not found.
// If you want to use the your own NotFound handler, please overwrite this variable.
//
// NotFound is called when unknown HTTP methods are being user or a handler not found.
//
// If you want to use your own NotFound handler, please overwrite this variable.
var NotFound = func(w http.ResponseWriter, r *http.Request, _ Params) {
http.NotFound(w, r)
}
-102
View File
@@ -1,102 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
// Copyright 2013 The Go Authors. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd.
// this file was taken from the github.com/golang/gddo repository
package middleware
import (
"net/http"
"strings"
"github.com/go-openapi/runtime/middleware/header"
)
// NegotiateContentEncoding returns the best offered content encoding for the
// request's Accept-Encoding header. If two offers match with equal weight and
// then the offer earlier in the list is preferred. If no offers are
// acceptable, then "" is returned.
func NegotiateContentEncoding(r *http.Request, offers []string) string {
bestOffer := "identity"
bestQ := -1.0
specs := header.ParseAccept(r.Header, "Accept-Encoding")
for _, offer := range offers {
for _, spec := range specs {
if spec.Q > bestQ &&
(spec.Value == "*" || spec.Value == offer) {
bestQ = spec.Q
bestOffer = offer
}
}
}
if bestQ == 0 {
bestOffer = ""
}
return bestOffer
}
// NegotiateContentType returns the best offered content type for the request's
// Accept header. If two offers match with equal weight, then the more specific
// offer is preferred. For example, text/* trumps */*. If two offers match
// with equal weight and specificity, then the offer earlier in the list is
// preferred. If no offers match, then defaultOffer is returned.
func NegotiateContentType(r *http.Request, offers []string, defaultOffer string) string {
bestOffer := defaultOffer
bestQ := -1.0
bestWild := 3
specs := header.ParseAccept(r.Header, "Accept")
for _, rawOffer := range offers {
offer := normalizeOffer(rawOffer)
// No Accept header: just return the first offer.
if len(specs) == 0 {
return rawOffer
}
for _, spec := range specs {
switch {
case spec.Q == 0.0:
// ignore
case spec.Q < bestQ:
// better match found
case spec.Value == "*/*":
if spec.Q > bestQ || bestWild > 2 {
bestQ = spec.Q
bestWild = 2
bestOffer = rawOffer
}
case strings.HasSuffix(spec.Value, "/*"):
if strings.HasPrefix(offer, spec.Value[:len(spec.Value)-1]) &&
(spec.Q > bestQ || bestWild > 1) {
bestQ = spec.Q
bestWild = 1
bestOffer = rawOffer
}
default:
if spec.Value == offer &&
(spec.Q > bestQ || bestWild > 0) {
bestQ = spec.Q
bestWild = 0
bestOffer = rawOffer
}
}
}
}
return bestOffer
}
func normalizeOffers(orig []string) (norm []string) {
for _, o := range orig {
norm = append(norm, normalizeOffer(o))
}
return
}
func normalizeOffer(orig string) string {
const maxParts = 2
return strings.SplitN(orig, ";", maxParts)[0]
}
+181 -123
View File
@@ -6,7 +6,7 @@ package middleware
import (
"encoding"
"encoding/base64"
"fmt"
stderrors "errors"
"io"
"net/http"
"reflect"
@@ -56,126 +56,153 @@ func (p *untypedParamBinder) Type() reflect.Type {
}
func (p *untypedParamBinder) Bind(request *http.Request, routeParams RouteParams, consumer runtime.Consumer, target reflect.Value) error {
// fmt.Println("binding", p.name, "as", p.Type())
switch p.parameter.In {
case "query":
data, custom, hasKey, err := p.readValue(runtime.Values(request.URL.Query()), target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
return p.bindQuery(request, routeParams, consumer, target)
case "header":
data, custom, hasKey, err := p.readValue(runtime.Values(request.Header), target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
return p.bindHeader(request, routeParams, consumer, target)
case "path":
data, custom, hasKey, err := p.readValue(routeParams, target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
return p.bindPath(request, routeParams, consumer, target)
case "formData":
var err error
var mt string
return p.bindFormData(request, routeParams, consumer, target)
mt, _, e := runtime.ContentType(request.Header)
if e != nil {
// because of the interface conversion go thinks the error is not nil
// so we first check for nil and then set the err var if it's not nil
err = e
}
case "body":
return p.bindBody(request, routeParams, consumer, target)
default:
return errors.New(http.StatusInternalServerError, "invalid parameter location: %q", p.parameter.In)
}
}
if err != nil {
return errors.InvalidContentType("", []string{"multipart/form-data", "application/x-www-form-urlencoded"})
}
func (p *untypedParamBinder) bindQuery(request *http.Request, _ RouteParams, _ runtime.Consumer, target reflect.Value) error {
data, custom, hasKey, err := p.readValue(runtime.Values(request.URL.Query()), target)
if err != nil {
return err
}
if custom {
return nil
}
if mt != "multipart/form-data" && mt != "application/x-www-form-urlencoded" {
return errors.InvalidContentType(mt, []string{"multipart/form-data", "application/x-www-form-urlencoded"})
}
return p.bindValue(data, hasKey, target)
}
if mt == "multipart/form-data" {
if err = request.ParseMultipartForm(defaultMaxMemory); err != nil {
return errors.NewParseError(p.Name, p.parameter.In, "", err)
}
}
func (p *untypedParamBinder) bindHeader(request *http.Request, _ RouteParams, _ runtime.Consumer, target reflect.Value) error {
data, custom, hasKey, err := p.readValue(runtime.Values(request.Header), target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
}
if err = request.ParseForm(); err != nil {
return errors.NewParseError(p.Name, p.parameter.In, "", err)
}
func (p *untypedParamBinder) bindPath(_ *http.Request, routeParams RouteParams, _ runtime.Consumer, target reflect.Value) error {
data, custom, hasKey, err := p.readValue(routeParams, target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
}
if p.parameter.Type == "file" {
file, header, ffErr := request.FormFile(p.parameter.Name)
if ffErr != nil {
func (p *untypedParamBinder) bindFormData(request *http.Request, _ RouteParams, _ runtime.Consumer, target reflect.Value) error {
mt, _, ctErr := runtime.ContentType(request.Header)
if ctErr != nil {
return errors.InvalidContentType("", []string{runtime.MultipartFormMime, runtime.URLencodedFormMime})
}
if mt != runtime.MultipartFormMime && mt != runtime.URLencodedFormMime {
return errors.InvalidContentType(mt, []string{runtime.MultipartFormMime, runtime.URLencodedFormMime})
}
// Parse via the shared helper. The helper routes on Content-Type
// (multipart/form-data → ParseMultipartForm; all non-multipart types,
// including application/x-www-form-urlencoded, → ParseForm)
// and applies the default 32 MiB body cap via http.MaxBytesReader.
// Idempotent across the per-parameter loop: stdlib short-circuits
// when r.MultipartForm / r.PostForm are already populated.
if _, perr := runtime.BindForm(request, runtime.BindFormMaxParseMemory(defaultMaxMemory)); perr != nil {
return perr
}
if p.parameter.Type == "file" {
// runtime.FormFile handles both multipart/form-data and
// application/x-www-form-urlencoded (OpenAPI 2.0 permits
// either consumes for `type: file`), and surfaces a
// missing field as http.ErrMissingFile under both.
file, header, ffErr := runtime.FormFile(request, p.parameter.Name)
if ffErr != nil {
if stderrors.Is(ffErr, http.ErrMissingFile) {
if p.parameter.Required {
return errors.NewParseError(p.Name, p.parameter.In, "", ffErr)
return errors.NewParseError(p.Name, p.parameter.In, "", http.ErrMissingFile)
}
return nil
}
target.Set(reflect.ValueOf(runtime.File{Data: file, Header: header}))
return nil
return errors.NewParseError(p.Name, p.parameter.In, "", ffErr)
}
if request.MultipartForm != nil {
data, custom, hasKey, rvErr := p.readValue(runtime.Values(request.MultipartForm.Value), target)
if rvErr != nil {
return rvErr
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
}
data, custom, hasKey, err := p.readValue(runtime.Values(request.PostForm), target)
if err != nil {
// Mirror the FileHeader.Filename length cap that BindForm
// applies to typed (codegen) paths through BindFormFile, so
// untyped formData bindings get the same protection.
if err := runtime.ValidateFilenameLength(p.Name, p.parameter.In, header.Filename,
runtime.DefaultMaxUploadFilenameLength); err != nil {
return err
}
target.Set(reflect.ValueOf(runtime.File{Data: file, Header: header}))
return nil
}
if request.MultipartForm != nil {
data, custom, hasKey, rvErr := p.readValue(runtime.Values(request.MultipartForm.Value), target)
if rvErr != nil {
return rvErr
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
}
data, custom, hasKey, err := p.readValue(runtime.Values(request.PostForm), target)
if err != nil {
return err
}
if custom {
return nil
}
return p.bindValue(data, hasKey, target)
}
case "body":
newValue := reflect.New(target.Type())
if !runtime.HasBody(request) {
if p.parameter.Default != nil {
target.Set(reflect.ValueOf(p.parameter.Default))
}
func (p *untypedParamBinder) bindBody(request *http.Request, _ RouteParams, consumer runtime.Consumer, target reflect.Value) error {
newValue := reflect.New(target.Type())
if !runtime.HasBody(request) {
if p.parameter.Default != nil {
target.Set(reflect.ValueOf(p.parameter.Default))
}
return nil
}
if err := consumer.Consume(request.Body, newValue.Interface()); err != nil {
if stderrors.Is(err, io.EOF) && p.parameter.Default != nil {
target.Set(reflect.ValueOf(p.parameter.Default))
return nil
}
if err := consumer.Consume(request.Body, newValue.Interface()); err != nil {
if err == io.EOF && p.parameter.Default != nil {
target.Set(reflect.ValueOf(p.parameter.Default))
return nil
}
tpe := p.parameter.Type
if p.parameter.Format != "" {
tpe = p.parameter.Format
}
return errors.InvalidType(p.Name, p.parameter.In, tpe, nil)
tpe := p.parameter.Type
if p.parameter.Format != "" {
tpe = p.parameter.Format
}
target.Set(reflect.Indirect(newValue))
return nil
default:
return fmt.Errorf("%d: invalid parameter location %q", http.StatusInternalServerError, p.parameter.In)
return errors.InvalidType(p.Name, p.parameter.In, tpe, nil)
}
target.Set(reflect.Indirect(newValue))
return nil
}
func (p *untypedParamBinder) typeForSchema(tpe, format string, items *spec.Items) reflect.Type {
@@ -261,20 +288,51 @@ func (p *untypedParamBinder) bindValue(data []string, hasKey bool, target reflec
if p.parameter.Type == typeArray {
return p.setSliceFieldValue(target, p.parameter.Default, data, hasKey)
}
var d string
if len(data) > 0 {
d = data[len(data)-1]
}
return p.setFieldValue(target, p.parameter.Default, d, hasKey)
}
func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue any, data string, hasKey bool) error { //nolint:gocyclo
func (p *untypedParamBinder) isMissingAndRequired(hasKey bool, data string) bool {
return p.parameter.Required &&
p.parameter.Default == nil &&
(!hasKey || (!p.parameter.AllowEmptyValue && data == ""))
}
func (p *untypedParamBinder) setByte(target, defVal reflect.Value, tpe, data string) error {
if data == "" {
if target.CanSet() {
target.SetBytes(defVal.Bytes())
}
return nil
}
b, err := base64.StdEncoding.DecodeString(data)
if err != nil {
b, err = base64.URLEncoding.DecodeString(data)
if err != nil {
return errors.InvalidType(p.Name, p.parameter.In, tpe, data)
}
}
if target.CanSet() {
target.SetBytes(b)
}
return nil
}
func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue any, data string, hasKey bool) error {
tpe := p.parameter.Type
if p.parameter.Format != "" {
tpe = p.parameter.Format
}
if (!hasKey || (!p.parameter.AllowEmptyValue && data == "")) && p.parameter.Required && p.parameter.Default == nil {
if p.isMissingAndRequired(hasKey, data) {
return errors.Required(p.Name, p.parameter.In, data)
}
@@ -292,27 +350,15 @@ func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue an
}
if tpe == "byte" {
if data == "" {
if target.CanSet() {
target.SetBytes(defVal.Bytes())
}
return nil
}
b, err := base64.StdEncoding.DecodeString(data)
if err != nil {
b, err = base64.URLEncoding.DecodeString(data)
if err != nil {
return errors.InvalidType(p.Name, p.parameter.In, tpe, data)
}
}
if target.CanSet() {
target.SetBytes(b)
}
return nil
return p.setByte(target, defVal, tpe, data)
}
switch target.Kind() { //nolint:exhaustive // we want to check only types that map from a swagger parameter
return p.setReflectFieldValue(target, defVal, tpe, data, hasKey)
}
//nolint:gocyclo,cyclop // not much we can simplify further significantly: the big case with all types is unavoidable.
func (p *untypedParamBinder) setReflectFieldValue(target, defVal reflect.Value, tpe, data string, hasKey bool) error {
switch target.Kind() { // we want to check only types that map from a swagger parameter
case reflect.Bool:
if data == "" {
if target.CanSet() {
@@ -327,6 +373,7 @@ func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue an
if target.CanSet() {
target.SetBool(b)
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if data == "" {
if target.CanSet() {
@@ -394,8 +441,8 @@ func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue an
target.SetString(value)
}
case reflect.Ptr:
if data == "" && defVal.Kind() == reflect.Ptr {
case reflect.Pointer:
if data == "" && defVal.Kind() == reflect.Pointer {
if target.CanSet() {
target.Set(defVal)
}
@@ -412,6 +459,7 @@ func (p *untypedParamBinder) setFieldValue(target reflect.Value, defaultValue an
default:
return errors.InvalidType(p.Name, p.parameter.In, tpe, data)
}
return nil
}
@@ -419,20 +467,30 @@ func (p *untypedParamBinder) tryUnmarshaler(target reflect.Value, defaultValue a
if !target.CanSet() {
return false, nil
}
// When a type implements encoding.TextUnmarshaler we'll use that instead of reflecting some more
if reflect.PointerTo(target.Type()).Implements(textUnmarshalType) {
if defaultValue != nil && len(data) == 0 {
target.Set(reflect.ValueOf(defaultValue))
return true, nil
}
value := reflect.New(target.Type())
if err := value.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(data)); err != nil {
return true, err
}
target.Set(reflect.Indirect(value))
ttyp := target.Type()
if !reflect.PointerTo(ttyp).Implements(textUnmarshalType) {
return false, nil
}
if defaultValue != nil && len(data) == 0 {
target.Set(reflect.ValueOf(defaultValue))
return true, nil
}
return false, nil
value := reflect.New(ttyp)
if !value.CanInterface() {
return false, nil
}
if err := value.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(data)); err != nil { //nolint:forcetypeassert // this is guaranteed by the reflect check above
return true, err
}
target.Set(reflect.Indirect(value))
return true, nil
}
func (p *untypedParamBinder) readFormattedSliceFieldValue(data string, target reflect.Value) ([]string, bool, error) {
-83
View File
@@ -1,83 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// RapiDocOpts configures the [RapiDoc] middlewares.
type RapiDocOpts struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// RapiDocURL points to the js asset that generates the rapidoc site.
//
// Defaults to https://unpkg.com/rapidoc/dist/rapidoc-min.js
RapiDocURL string
}
func (r *RapiDocOpts) EnsureDefaults() {
common := toCommonUIOptions(r)
common.EnsureDefaults()
fromCommonToAnyOptions(common, r)
// rapidoc-specifics
if r.RapiDocURL == "" {
r.RapiDocURL = rapidocLatest
}
if r.Template == "" {
r.Template = rapidocTemplate
}
}
// RapiDoc creates a [middleware] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler {
opts.EnsureDefaults()
pth := path.Join(opts.BasePath, opts.Path)
tmpl := template.Must(template.New("rapidoc").Parse(opts.Template))
assets := bytes.NewBuffer(nil)
if err := tmpl.Execute(assets, opts); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return serveUI(pth, assets.Bytes(), next)
}
const (
rapidocLatest = "https://unpkg.com/rapidoc/dist/rapidoc-min.js"
rapidocTemplate = `<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
<script type="module" src="{{ .RapiDocURL }}"></script>
</head>
<body>
<rapi-doc spec-url="{{ .SpecURL }}"></rapi-doc>
</body>
</html>
`
)
-97
View File
@@ -1,97 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// RedocOpts configures the [Redoc] middlewares.
type RedocOpts struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// RedocURL points to the js that generates the redoc site.
//
// Defaults to: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js
RedocURL string
}
// EnsureDefaults in case some options are missing.
func (r *RedocOpts) EnsureDefaults() {
common := toCommonUIOptions(r)
common.EnsureDefaults()
fromCommonToAnyOptions(common, r)
// redoc-specifics
if r.RedocURL == "" {
r.RedocURL = redocLatest
}
if r.Template == "" {
r.Template = redocTemplate
}
}
// Redoc creates a [middleware] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
func Redoc(opts RedocOpts, next http.Handler) http.Handler {
opts.EnsureDefaults()
pth := path.Join(opts.BasePath, opts.Path)
tmpl := template.Must(template.New("redoc").Parse(opts.Template))
assets := bytes.NewBuffer(nil)
if err := tmpl.Execute(assets, opts); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return serveUI(pth, assets.Bytes(), next)
}
const (
redocLatest = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"
redocTemplate = `<!DOCTYPE html>
<html>
<head>
<title>{{ .Title }}</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='{{ .SpecURL }}'></redoc>
<script src="{{ .RedocURL }}"> </script>
</body>
</html>
`
)
+17 -7
View File
@@ -40,8 +40,25 @@ func NewUntypedRequestBinder(parameters map[string]spec.Parameter, spec *spec.Sw
// Bind perform the databinding and validation.
func (o *UntypedRequestBinder) Bind(request *http.Request, routeParams RouteParams, consumer runtime.Consumer, data any) error {
err := o.bind(request, routeParams, consumer, data)
if err == nil {
return nil // avoids returning a nil-interface
}
return err
}
// SetLogger allows for injecting a logger to catch debug entries.
//
// The logger is enabled in DEBUG mode only.
func (o *UntypedRequestBinder) SetLogger(lg logger.Logger) {
o.debugLogf = debugLogfFunc(lg)
}
func (o *UntypedRequestBinder) bind(request *http.Request, routeParams RouteParams, consumer runtime.Consumer, data any) *errors.CompositeError {
val := reflect.Indirect(reflect.ValueOf(data))
isMap := val.Kind() == reflect.Map
var result []error
o.debugLogf("binding %d parameters for %s %s", len(o.Parameters), request.Method, request.URL.EscapedPath())
for fieldName, param := range o.Parameters {
@@ -94,13 +111,6 @@ func (o *UntypedRequestBinder) Bind(request *http.Request, routeParams RoutePara
return nil
}
// SetLogger allows for injecting a logger to catch debug entries.
//
// The logger is enabled in DEBUG mode only.
func (o *UntypedRequestBinder) SetLogger(lg logger.Logger) {
o.debugLogf = debugLogfFunc(lg)
}
func (o *UntypedRequestBinder) setDebugLogf(fn func(string, ...any)) {
o.debugLogf = fn
}
+73 -48
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-openapi/spec"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag/stringutils"
"github.com/go-openapi/swag/typeutils"
)
// RouteParam is a object to capture route params in a framework agnostic way.
@@ -292,7 +293,7 @@ func (ras RouteAuthenticators) Authenticate(req *http.Request, route *MatchedRou
continue
}
applies, usr, err := ra.Authenticate(req, route)
if !applies || err != nil || usr == nil {
if !applies || err != nil || typeutils.IsZero(usr) {
if err != nil {
lastError = err
}
@@ -348,49 +349,62 @@ func (m *MatchedRoute) NeedsAuth() bool {
func (d *defaultRouter) Lookup(method, path string) (*MatchedRoute, bool) {
mth := strings.ToUpper(method)
d.debugLogf("looking up route for %s %s", method, path)
if Debug {
if len(d.routers) == 0 {
if len(d.routers) == 0 {
if Debug {
d.debugLogf("there are no known routers")
}
panic("internal error: no router is configured")
}
if Debug {
for meth := range d.routers {
d.debugLogf("got a router for %s", meth)
}
}
if router, ok := d.routers[mth]; ok {
if m, rp, ok := router.Lookup(fpath.Clean(path)); ok && m != nil {
if entry, ok := m.(*routeEntry); ok {
d.debugLogf("found a route for %s %s with %d parameters", method, path, len(entry.Parameters))
var params RouteParams
for _, p := range rp {
v, err := url.PathUnescape(p.Value)
if err != nil {
d.debugLogf("failed to escape %q: %v", p.Value, err)
v = p.Value
}
// a workaround to handle fragment/composing parameters until they are supported in denco router
// check if this parameter is a fragment within a path segment
const enclosureSize = 2
if xpos := strings.Index(entry.PathPattern, fmt.Sprintf("{%s}", p.Name)) + len(p.Name) + enclosureSize; xpos < len(entry.PathPattern) && entry.PathPattern[xpos] != '/' {
// extract fragment parameters
ep := strings.Split(entry.PathPattern[xpos:], "/")[0]
pnames, pvalues := decodeCompositParams(p.Name, v, ep, nil, nil)
for i, pname := range pnames {
params = append(params, RouteParam{Name: pname, Value: pvalues[i]})
}
} else {
// use the parameter directly
params = append(params, RouteParam{Name: p.Name, Value: v})
}
}
return &MatchedRoute{routeEntry: *entry, Params: params}, true
router, ok := d.routers[mth]
if !ok {
d.debugLogf("couldn't find a route by method for %s %s", method, path)
return nil, false
}
m, rp, ok := router.Lookup(fpath.Clean(escapeLiteralColons(path)))
if !ok || m == nil {
d.debugLogf("couldn't find a route by path for %s %s", method, path)
return nil, false
}
entry, ok := m.(*routeEntry)
if !ok {
return nil, false
}
d.debugLogf("found a route for %s %s with %d parameters", method, path, len(entry.Parameters))
var params RouteParams
for _, p := range rp {
v, err := url.PathUnescape(p.Value)
if err != nil {
d.debugLogf("failed to escape %q: %v", p.Value, err)
v = p.Value
}
// a workaround to handle fragment/composing parameters until they are supported in denco router
// check if this parameter is a fragment within a path segment
const enclosureSize = 2
if xpos := strings.Index(entry.PathPattern, fmt.Sprintf("{%s}", p.Name)) + len(p.Name) + enclosureSize; xpos < len(entry.PathPattern) && entry.PathPattern[xpos] != '/' {
// extract fragment parameters
ep := strings.Split(entry.PathPattern[xpos:], "/")[0]
pnames, pvalues := decodeCompositParams(p.Name, v, ep, nil, nil)
for i, pname := range pnames {
params = append(params, RouteParam{Name: pname, Value: pvalues[i]})
}
} else {
d.debugLogf("couldn't find a route by path for %s %s", method, path)
// use the parameter directly
params = append(params, RouteParam{Name: p.Name, Value: v})
}
} else {
d.debugLogf("couldn't find a route by method for %s %s", method, path)
}
return nil, false
return &MatchedRoute{routeEntry: *entry, Params: params}, true
}
func (d *defaultRouter) OtherMethods(method, path string) []string {
@@ -398,7 +412,7 @@ func (d *defaultRouter) OtherMethods(method, path string) []string {
var methods []string
for k, v := range d.routers {
if k != mn {
if _, _, ok := v.Lookup(fpath.Clean(path)); ok {
if _, _, ok := v.Lookup(fpath.Clean(escapeLiteralColons(path))); ok {
methods = append(methods, k)
continue
}
@@ -414,28 +428,39 @@ func (d *defaultRouter) SetLogger(lg logger.Logger) {
// convert swagger parameters per path segment into a denco parameter as multiple parameters per segment are not supported in denco.
var pathConverter = regexp.MustCompile(`{(.+?)}([^/]*)`)
// escapeLiteralColons replaces literal ':' characters with their URL-encoded
// equivalent "%3A". This prevents the denco router from misinterpreting ':'
// in URL path segments as parameter delimiters. The ':' character is valid in
// URL paths per RFC 3986 section 3.3.
func escapeLiteralColons(path string) string {
return strings.ReplaceAll(path, ":", "%3A")
}
func decodeCompositParams(name string, value string, pattern string, names []string, values []string) ([]string, []string) {
pleft := strings.Index(pattern, "{")
names = append(names, name)
if pleft < 0 {
if strings.HasSuffix(value, pattern) {
values = append(values, value[:len(value)-len(pattern)])
} else {
values = append(values, "")
}
} else {
toskip := pattern[:pleft]
pright := strings.Index(pattern, "}")
vright := strings.Index(value, toskip)
if vright >= 0 {
values = append(values, value[:vright])
} else {
values = append(values, "")
value = ""
}
return decodeCompositParams(pattern[pleft+1:pright], value[vright+len(toskip):], pattern[pright+1:], names, values)
return names, values
}
return names, values
toskip := pattern[:pleft]
pright := strings.Index(pattern, "}")
vright := strings.Index(value, toskip)
if vright >= 0 {
values = append(values, value[:vright])
} else {
values = append(values, "")
value = ""
}
return decodeCompositParams(pattern[pleft+1:pright], value[vright+len(toskip):], pattern[pright+1:], names, values)
}
func (d *defaultRouteBuilder) AddRoute(method, path string, operation *spec.Operation) {
@@ -463,7 +488,7 @@ func (d *defaultRouteBuilder) AddRoute(method, path string, operation *spec.Oper
requestBinder := NewUntypedRequestBinder(parameters, d.spec.Spec(), d.api.Formats())
requestBinder.setDebugLogf(d.debugLogf)
record := denco.NewRecord(pathConverter.ReplaceAllString(path, ":$1"), &routeEntry{
record := denco.NewRecord(pathConverter.ReplaceAllString(escapeLiteralColons(path), ":$1"), &routeEntry{
BasePath: bp,
PathPattern: path,
Operation: operation,
+482
View File
@@ -0,0 +1,482 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"net/http"
"path"
"strings"
"github.com/go-openapi/runtime/server-middleware/docui"
"github.com/go-openapi/runtime/server-middleware/negotiate"
)
/////////////////////////////////////////////////////////:
// Seam to the negotiate options introduced in v0.29.5
/////////////////////////////////////////////////////////:
// NegotiateOption configures [NegotiateContentType] behaviour.
//
// Deprecated: moved to the [negotiate] package. Use [negotiate.Option] instead.
type NegotiateOption = negotiate.Option
// NegotiateContentType returns the best offered content type for the
// request's Accept header.
//
// Deprecated: moved to the [negotiate] package. Use [negotiate.ContentType] instead.
func NegotiateContentType(r *http.Request, offers []string, defaultOffer string, opts ...NegotiateOption) string {
return negotiate.ContentType(r, offers, defaultOffer, opts...)
}
// NegotiateContentEncoding returns the best offered content encoding for
// the request's Accept-Encoding header.
//
// Deprecated: moved to the [negotiate] package. Use [negotiate.ContentEncoding] instead.
func NegotiateContentEncoding(r *http.Request, offers []string) string {
return negotiate.ContentEncoding(r, offers)
}
// WithIgnoreParameters returns a [NegotiateOption] that strips MIME-type
// parameters from both Accept entries and offers before matching,
// restoring the pre-v0.30 behaviour.
//
// Deprecated: moved to the [negotiate] package. Use [negotiate.WithIgnoreParameters] instead.
func WithIgnoreParameters(ignore bool) NegotiateOption {
return negotiate.WithIgnoreParameters(ignore)
}
/////////////////////////////////////////////////////////:
// Seam to the UI options
/////////////////////////////////////////////////////////:
// RapiDoc creates a [http.Handler] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
//
// Deprecated: moved to the [docui] package. Use [docui.RapiDoc] instead.
func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler {
return docui.RapiDoc(next, opts.toFuncOptions()...)
}
// Redoc creates a [http.Handler] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
//
// Deprecated: moved to the [docui] package. Use [docui.Redoc] instead.
func Redoc(opts RedocOpts, next http.Handler) http.Handler {
return docui.Redoc(next, opts.toFuncOptions()...)
}
// SwaggerUI creates a [http.Handler] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
//
// Deprecated: moved to the [docui] package. Use [docui.SwaggerUI] instead.
func SwaggerUI(opts SwaggerUIOpts, next http.Handler) http.Handler {
return docui.SwaggerUI(next, opts.toFuncOptions()...)
}
// SwaggerUIOAuth2Callback creates a middleware that serves the OAuth2 callback page used by Swagger UI.
//
// Deprecated: moved to the [docui] package. Use [docui.SwaggerUIOAuth2Callback] instead.
func SwaggerUIOAuth2Callback(opts SwaggerUIOpts, next http.Handler) http.Handler {
return docui.SwaggerUIOAuth2Callback(next, opts.toFuncOptions()...)
}
/////////////////////////////////////////////////////////:
// Seam to the spec middleware options
/////////////////////////////////////////////////////////:
// SpecOption can be applied to the [Spec] serving [middleware].
//
// Deprecated: moved to the [docui] package. Use [docui.SpecOption] instead.
type SpecOption func(*specOptions)
type specOptions struct {
BasePath string
Path string
Document string
}
func (o specOptions) fullPath() string {
return path.Join(o.BasePath, o.Path, o.Document)
}
func specOptionsWithDefaults(basePath string, opts []SpecOption) specOptions {
o := specOptions{
BasePath: "/",
Path: "",
Document: "swagger.json",
}
for _, apply := range opts {
apply(&o)
}
if basePath != "" {
o.BasePath = basePath
}
return o
}
// Spec creates a [middleware] to serve a swagger spec as a JSON document.
//
// This allows for altering the spec before starting the [http] listener.
//
// The basePath argument indicates the path of the spec document (defaults to "/").
// Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json").
//
// Deprecated: moved to the [docui] package as [docui.ServeSpec].
func Spec(basePath string, spec []byte, next http.Handler, opts ...SpecOption) http.Handler {
o := specOptionsWithDefaults(basePath, opts)
return docui.ServeSpec(spec, next, docui.WithSpecPath(o.fullPath()))
}
// WithSpecPath sets the path to be joined to the base path of the
// spec-serving middleware (see [docui.ServeSpec]).
//
// This is empty by default.
func WithSpecPath(pth string) SpecOption {
return func(o *specOptions) {
o.Path = pth
}
}
// WithSpecDocument sets the name of the JSON document served as a spec.
//
// By default, this is "swagger.json".
func WithSpecDocument(doc string) SpecOption {
return func(o *specOptions) {
if doc == "" {
return
}
o.Document = doc
}
}
// UIOptions defines common options for UI serving middlewares.
//
// Deprecated: use instead the function options provided by [docui].
type UIOptions struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
}
// toFuncOptions bridges the deprecated options struct with the newer function options in [docui].
func (o UIOptions) toFuncOptions() []docui.Option {
const structMembers = 5
opts := make([]docui.Option, 0, structMembers)
if o.BasePath != "" {
opts = append(opts, docui.WithUIBasePath(o.BasePath))
}
if o.Path != "" {
opts = append(opts, docui.WithUIPath(o.Path))
}
if o.SpecURL != "" {
opts = append(opts, docui.WithSpecURL(o.SpecURL))
}
if o.Title != "" {
opts = append(opts, docui.WithUITitle(o.Title))
}
if o.Template != "" {
opts = append(opts, docui.WithUITemplate(o.Template))
}
return opts
}
// RapiDocOpts configures the [RapiDoc] middlewares.
//
// Deprecated: use instead the function options provided by [docui].
type RapiDocOpts struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// RapiDocURL points to the js asset that generates the rapidoc site.
//
// Defaults to https://unpkg.com/rapidoc/dist/rapidoc-min.js
RapiDocURL string
}
func (o RapiDocOpts) toFuncOptions() []docui.Option {
const structMembers = 6
opts := make([]docui.Option, 0, structMembers)
if o.BasePath != "" {
opts = append(opts, docui.WithUIBasePath(o.BasePath))
}
if o.Path != "" {
opts = append(opts, docui.WithUIPath(o.Path))
}
if o.SpecURL != "" {
opts = append(opts, docui.WithSpecURL(o.SpecURL))
}
if o.Title != "" {
opts = append(opts, docui.WithUITitle(o.Title))
}
if o.Template != "" {
opts = append(opts, docui.WithUITemplate(o.Template))
}
if o.RapiDocURL != "" {
opts = append(opts, docui.WithUIAssetsURL(o.RapiDocURL))
}
return opts
}
// RedocOpts configures the [Redoc] middlewares.
//
// Deprecated: use instead the function options provided by [docui].
type RedocOpts struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// RedocURL points to the js that generates the redoc site.
//
// Defaults to: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js
RedocURL string
}
func (o RedocOpts) toFuncOptions() []docui.Option {
const structMembers = 6
opts := make([]docui.Option, 0, structMembers)
if o.BasePath != "" {
opts = append(opts, docui.WithUIBasePath(o.BasePath))
}
if o.Path != "" {
opts = append(opts, docui.WithUIPath(o.Path))
}
if o.SpecURL != "" {
opts = append(opts, docui.WithSpecURL(o.SpecURL))
}
if o.Title != "" {
opts = append(opts, docui.WithUITitle(o.Title))
}
if o.Template != "" {
opts = append(opts, docui.WithUITemplate(o.Template))
}
if o.RedocURL != "" {
opts = append(opts, docui.WithUIAssetsURL(o.RedocURL))
}
return opts
}
// SwaggerUIOpts configures the [SwaggerUI] [middleware].
//
// Deprecated: use instead the function options provided by [docui].
type SwaggerUIOpts struct {
// BasePath for the API, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// OAuthCallbackURL the url called after OAuth2 login
//
// NOTE: in the new [docui.SwaggerUIOptions] type, this field is named `OAuth2CallbackURL`,
// which is more appropriate.
OAuthCallbackURL string
// The three components needed to embed swagger-ui
// SwaggerURL points to the js that generates the SwaggerUI site.
//
// Defaults to: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js
SwaggerURL string
SwaggerPresetURL string
SwaggerStylesURL string
Favicon32 string
Favicon16 string
}
func (o SwaggerUIOpts) toFuncOptions() []docui.Option {
const structMembers = 6
opts := make([]docui.Option, 0, structMembers)
if o.BasePath != "" {
opts = append(opts, docui.WithUIBasePath(o.BasePath))
}
if o.Path != "" {
opts = append(opts, docui.WithUIPath(o.Path))
}
if o.SpecURL != "" {
opts = append(opts, docui.WithSpecURL(o.SpecURL))
}
if o.Title != "" {
opts = append(opts, docui.WithUITitle(o.Title))
}
if o.Template != "" {
opts = append(opts, docui.WithUITemplate(o.Template))
}
if o.SwaggerURL != "" {
opts = append(opts, docui.WithUIAssetsURL(o.SwaggerURL))
}
var empty SwaggerUIOpts
if o != empty {
swaggeruiOpts := docui.SwaggerUIOptions{
OAuth2CallbackURL: o.OAuthCallbackURL,
SwaggerPresetURL: o.SwaggerPresetURL,
SwaggerStylesURL: o.SwaggerStylesURL,
Favicon32: o.Favicon32,
Favicon16: o.Favicon16,
}
opts = append(opts, docui.WithSwaggerUIOptions(swaggeruiOpts))
}
return opts
}
// UIOption can be applied to UI serving [middleware] to alter the default
// behavior.
//
// Deprecated: use instead the function options provided by [docui].
type UIOption func(*UIOptions)
// uiOptionsWithDefaults applies the given options on top of an empty
// [UIOptions]. Per-flavor handlers ([SwaggerUI], [Redoc], [RapiDoc])
// fill in the remaining defaults via [UIOptions.EnsureDefaults] when
// the option struct is used.
func uiOptionsWithDefaults(opts []UIOption) UIOptions {
var o UIOptions
for _, apply := range opts {
apply(&o)
}
return o
}
// WithUIBasePath sets the base path from where to serve the UI assets.
//
// Deprecated: use instead the function options provided by [docui].
func WithUIBasePath(base string) UIOption {
return func(o *UIOptions) {
if !strings.HasPrefix(base, "/") {
base = "/" + base
}
o.BasePath = base
}
}
// WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}.
//
// Deprecated: use instead the function options provided by [docui].
func WithUIPath(pth string) UIOption {
return func(o *UIOptions) {
o.Path = pth
}
}
// WithUISpecURL sets the path from where to serve swagger spec document.
//
// This may be specified as a full URL or a path.
//
// By default, this is "/swagger.json".
//
// Deprecated: use instead the function options provided by [docui].
func WithUISpecURL(specURL string) UIOption {
return func(o *UIOptions) {
o.SpecURL = specURL
}
}
// WithUITitle sets the title of the UI.
//
// Deprecated: use instead the function options provided by [docui].
func WithUITitle(title string) UIOption {
return func(o *UIOptions) {
o.Title = title
}
}
// WithTemplate allows to set a custom template for the UI.
//
// UI [middleware] will panic if the template does not parse or execute properly.
//
// Deprecated: use instead the function options provided by [docui].
func WithTemplate(tpl string) UIOption {
return func(o *UIOptions) {
o.Template = tpl
}
}
-91
View File
@@ -1,91 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"net/http"
"path"
)
const (
contentTypeHeader = "Content-Type"
applicationJSON = "application/json"
)
// SpecOption can be applied to the Spec serving [middleware].
type SpecOption func(*specOptions)
var defaultSpecOptions = specOptions{
Path: "",
Document: "swagger.json",
}
type specOptions struct {
Path string
Document string
}
func specOptionsWithDefaults(opts []SpecOption) specOptions {
o := defaultSpecOptions
for _, apply := range opts {
apply(&o)
}
return o
}
// Spec creates a [middleware] to serve a swagger spec as a JSON document.
//
// This allows for altering the spec before starting the [http] listener.
//
// The basePath argument indicates the path of the spec document (defaults to "/").
// Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json").
func Spec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http.Handler {
if basePath == "" {
basePath = "/"
}
o := specOptionsWithDefaults(opts)
pth := path.Join(basePath, o.Path, o.Document)
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path.Clean(r.URL.Path) == pth {
rw.Header().Set(contentTypeHeader, applicationJSON)
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(b)
return
}
if next != nil {
next.ServeHTTP(rw, r)
return
}
rw.Header().Set(contentTypeHeader, applicationJSON)
rw.WriteHeader(http.StatusNotFound)
})
}
// WithSpecPath sets the path to be joined to the base path of the Spec [middleware].
//
// This is empty by default.
func WithSpecPath(pth string) SpecOption {
return func(o *specOptions) {
o.Path = pth
}
}
// WithSpecDocument sets the name of the JSON document served as a spec.
//
// By default, this is "swagger.json".
func WithSpecDocument(doc string) SpecOption {
return func(o *specOptions) {
if doc == "" {
return
}
o.Document = doc
}
}
-178
View File
@@ -1,178 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// SwaggerUIOpts configures the [SwaggerUI] [middleware].
type SwaggerUIOpts struct {
// BasePath for the API, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// OAuthCallbackURL the url called after OAuth2 login
OAuthCallbackURL string
// The three components needed to embed swagger-ui
// SwaggerURL points to the js that generates the SwaggerUI site.
//
// Defaults to: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js
SwaggerURL string
SwaggerPresetURL string
SwaggerStylesURL string
Favicon32 string
Favicon16 string
}
// EnsureDefaults in case some options are missing.
func (r *SwaggerUIOpts) EnsureDefaults() {
r.ensureDefaults()
if r.Template == "" {
r.Template = swaggeruiTemplate
}
}
func (r *SwaggerUIOpts) EnsureDefaultsOauth2() {
r.ensureDefaults()
if r.Template == "" {
r.Template = swaggerOAuthTemplate
}
}
func (r *SwaggerUIOpts) ensureDefaults() {
common := toCommonUIOptions(r)
common.EnsureDefaults()
fromCommonToAnyOptions(common, r)
// swaggerui-specifics
if r.OAuthCallbackURL == "" {
r.OAuthCallbackURL = path.Join(r.BasePath, r.Path, "oauth2-callback")
}
if r.SwaggerURL == "" {
r.SwaggerURL = swaggerLatest
}
if r.SwaggerPresetURL == "" {
r.SwaggerPresetURL = swaggerPresetLatest
}
if r.SwaggerStylesURL == "" {
r.SwaggerStylesURL = swaggerStylesLatest
}
if r.Favicon16 == "" {
r.Favicon16 = swaggerFavicon16Latest
}
if r.Favicon32 == "" {
r.Favicon32 = swaggerFavicon32Latest
}
}
// SwaggerUI creates a [middleware] to serve a documentation site for a swagger spec.
//
// This allows for altering the spec before starting the [http] listener.
func SwaggerUI(opts SwaggerUIOpts, next http.Handler) http.Handler {
opts.EnsureDefaults()
pth := path.Join(opts.BasePath, opts.Path)
tmpl := template.Must(template.New("swaggerui").Parse(opts.Template))
assets := bytes.NewBuffer(nil)
if err := tmpl.Execute(assets, opts); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return serveUI(pth, assets.Bytes(), next)
}
const (
swaggerLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"
swaggerPresetLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"
swaggerStylesLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui.css"
swaggerFavicon32Latest = "https://unpkg.com/swagger-ui-dist/favicon-32x32.png"
swaggerFavicon16Latest = "https://unpkg.com/swagger-ui-dist/favicon-16x16.png"
swaggeruiTemplate = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ .Title }}</title>
<link rel="stylesheet" type="text/css" href="{{ .SwaggerStylesURL }}" >
<link rel="icon" type="image/png" href="{{ .Favicon32 }}" sizes="32x32" />
<link rel="icon" type="image/png" href="{{ .Favicon16 }}" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{{ .SwaggerURL }}"> </script>
<script src="{{ .SwaggerPresetURL }}"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: '{{ .SpecURL }}',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
oauth2RedirectUrl: '{{ .OAuthCallbackURL }}'
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>
`
)
+30
View File
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import "strings"
// normalizeOffer strips the parameter section (";...") from a media-type
// string.
func normalizeOffer(orig string) string {
// NOTE(maintainers): Despite its name (kept for historical reasons), this helper is
// not about Accept negotiation — it is used to derive the bare type that
// keys the producer/consumer maps registered on a [RoutableAPI].
// Those maps are looked up by the bare media type, so an entry registered as
// "application/json" satisfies a route that declares "application/json;
// charset=utf-8" and vice-versa.
const maxParts = 2
return strings.SplitN(orig, ";", maxParts)[0]
}
// normalizeOffers is the slice form of [normalizeOffer].
func normalizeOffers(orig []string) []string {
norm := make([]string, 0, len(orig))
for _, o := range orig {
norm = append(norm, normalizeOffer(o))
}
return norm
}
-176
View File
@@ -1,176 +0,0 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"bytes"
"encoding/gob"
"fmt"
"net/http"
"path"
"strings"
)
const (
// constants that are common to all UI-serving middlewares.
defaultDocsPath = "docs"
defaultDocsURL = "/swagger.json"
defaultDocsTitle = "API Documentation"
)
// uiOptions defines common options for UI serving middlewares.
type uiOptions struct {
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
//
// Defaults to: /swagger.json
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
}
// toCommonUIOptions converts any UI option type to retain the common options.
//
// This uses gob encoding/decoding to convert common fields from one struct to another.
func toCommonUIOptions(opts any) uiOptions {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
dec := gob.NewDecoder(&buf)
var o uiOptions
err := enc.Encode(opts)
if err != nil {
panic(err)
}
err = dec.Decode(&o)
if err != nil {
panic(err)
}
return o
}
func fromCommonToAnyOptions[T any](source uiOptions, target *T) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
dec := gob.NewDecoder(&buf)
err := enc.Encode(source)
if err != nil {
panic(err)
}
err = dec.Decode(target)
if err != nil {
panic(err)
}
}
// UIOption can be applied to UI serving [middleware], such as Context.[APIHandler] or
// Context.[APIHandlerSwaggerUI] to alter the default behavior.
type UIOption func(*uiOptions)
func uiOptionsWithDefaults(opts []UIOption) uiOptions {
var o uiOptions
for _, apply := range opts {
apply(&o)
}
return o
}
// WithUIBasePath sets the base path from where to serve the UI assets.
//
// By default, Context [middleware] sets this value to the API base path.
func WithUIBasePath(base string) UIOption {
return func(o *uiOptions) {
if !strings.HasPrefix(base, "/") {
base = "/" + base
}
o.BasePath = base
}
}
// WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}.
func WithUIPath(pth string) UIOption {
return func(o *uiOptions) {
o.Path = pth
}
}
// WithUISpecURL sets the path from where to serve swagger spec document.
//
// This may be specified as a full URL or a path.
//
// By default, this is "/swagger.json".
func WithUISpecURL(specURL string) UIOption {
return func(o *uiOptions) {
o.SpecURL = specURL
}
}
// WithUITitle sets the title of the UI.
//
// By default, Context [middleware] sets this value to the title found in the API spec.
func WithUITitle(title string) UIOption {
return func(o *uiOptions) {
o.Title = title
}
}
// WithTemplate allows to set a custom template for the UI.
//
// UI [middleware] will panic if the template does not parse or execute properly.
func WithTemplate(tpl string) UIOption {
return func(o *uiOptions) {
o.Template = tpl
}
}
// EnsureDefaults in case some options are missing.
func (r *uiOptions) EnsureDefaults() {
if r.BasePath == "" {
r.BasePath = "/"
}
if r.Path == "" {
r.Path = defaultDocsPath
}
if r.SpecURL == "" {
r.SpecURL = defaultDocsURL
}
if r.Title == "" {
r.Title = defaultDocsTitle
}
}
// serveUI creates a middleware that serves a templated asset as text/html.
func serveUI(pth string, assets []byte, next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path.Clean(r.URL.Path) == pth {
rw.Header().Set(contentTypeHeader, "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(assets)
return
}
if next != nil {
next.ServeHTTP(rw, r)
return
}
rw.Header().Set(contentTypeHeader, "text/plain")
rw.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprintf(rw, "%q not found", pth)
})
}
+57 -45
View File
@@ -4,13 +4,14 @@
package middleware
import (
"mime"
stderrors "errors"
"net/http"
"strings"
"github.com/go-openapi/errors"
"github.com/go-openapi/runtime"
"github.com/go-openapi/swag/stringutils"
"github.com/go-openapi/runtime/server-middleware/mediatype"
)
type validation struct {
@@ -21,24 +22,28 @@ type validation struct {
bound map[string]any
}
// ContentType validates the content type of a request.
func validateContentType(allowed []string, actual string) error {
// validateContentType maps [mediatype.MatchFirst] to the runtime's
// validation errors:
//
// - actual fails to parse → HTTP 400 ([errors.NewParseError]).
// - actual is well-formed but
// no allowed entry accepts it → HTTP 415 ([errors.InvalidContentType]).
//
// In the standard runtime flow, malformed Content-Type headers are
// already caught upstream by [runtime.ContentType] (which itself returns
// a 400 [errors.ParseError]). This function therefore only sees the
// malformed case when invoked directly by callers that have bypassed
// that step.
func validateContentType(allowed []string, actual string, opts ...mediatype.MatchOption) error {
if len(allowed) == 0 {
return nil
}
mt, _, err := mime.ParseMediaType(actual)
_, ok, err := mediatype.MatchFirst(allowed, actual, opts...)
if ok {
return nil
}
if err != nil {
return errors.InvalidContentType(actual, allowed)
}
if stringutils.ContainsStringsCI(allowed, mt) {
return nil
}
if stringutils.ContainsStringsCI(allowed, "*/*") {
return nil
}
parts := strings.Split(actual, "/")
if len(parts) == 2 && stringutils.ContainsStringsCI(allowed, parts[0]+"/*") {
return nil
return errors.NewParseError(runtime.HeaderContentType, "header", actual, err)
}
return errors.InvalidContentType(actual, allowed)
}
@@ -69,46 +74,53 @@ func (v *validation) debugLogf(format string, args ...any) {
func (v *validation) parameters() {
v.debugLogf("validating request parameters for %s %s", v.request.Method, v.request.URL.EscapedPath())
if result := v.route.Binder.Bind(v.request, v.route.Params, v.route.Consumer, v.bound); result != nil {
if result.Error() == "validation failure list" {
for _, e := range result.(*errors.Validation).Value.([]any) {
v.result = append(v.result, e.(error))
}
return
result := v.route.Binder.bind(v.request, v.route.Params, v.route.Consumer, v.bound)
if result == nil {
return
}
for _, e := range result.Errors {
var validationErr *errors.Validation
if stderrors.As(e, &validationErr) {
v.result = append(v.result, validationErr)
}
v.result = append(v.result, result)
}
}
func (v *validation) contentType() {
if len(v.result) == 0 && runtime.HasBody(v.request) {
v.debugLogf("validating body content type for %s %s", v.request.Method, v.request.URL.EscapedPath())
ct, _, req, err := v.context.ContentType(v.request)
if err != nil {
v.result = append(v.result, err)
} else {
v.request = req
}
if len(v.result) > 0 || !runtime.HasBody(v.request) {
return
}
if len(v.result) == 0 {
v.debugLogf("validating content type for %q against [%s]", ct, strings.Join(v.route.Consumes, ", "))
if err := validateContentType(v.route.Consumes, ct); err != nil {
v.result = append(v.result, err)
}
}
if ct != "" && v.route.Consumer == nil {
cons, ok := v.route.Consumers[ct]
if !ok {
v.result = append(v.result, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct))
} else {
v.route.Consumer = cons
}
v.debugLogf("validating body content type for %s %s", v.request.Method, v.request.URL.EscapedPath())
ct, _, req, err := v.context.ContentType(v.request)
if err != nil {
v.result = append(v.result, err)
} else {
v.request = req
}
if len(v.result) == 0 {
v.debugLogf("validating content type for %q against [%s]", ct, strings.Join(v.route.Consumes, ", "))
if err := validateContentType(v.route.Consumes, ct, v.context.matchOpts()...); err != nil {
v.result = append(v.result, err)
}
}
if ct == "" || v.route.Consumer != nil {
return
}
cons, ok := mediatype.Lookup(v.route.Consumers, ct, v.context.matchOpts()...)
if !ok {
v.result = append(v.result, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct))
} else {
v.route.Consumer = cons
}
}
func (v *validation) responseFormat() {
// if the route provides values for Produces and no format could be identify then return an error.
// if the route provides values for Produces and no format could be identified then return an error.
// if the route does not specify values for Produces then treat request as valid since the API designer
// choose not to specify the format for responses.
if str, rCtx := v.context.ResponseFormat(v.request, v.route.Produces); str == "" && len(v.route.Produces) > 0 {
+37 -10
View File
@@ -19,8 +19,8 @@ const (
accessTokenParam = "access_token"
)
// HttpAuthenticator is a function that authenticates a HTTP request.
func HttpAuthenticator(handler func(*http.Request) (bool, any, error)) runtime.Authenticator { //nolint:revive
// HTTPAuthenticator is a function that authenticates a HTTP request.
func HTTPAuthenticator(handler func(*http.Request) (bool, any, error)) runtime.Authenticator {
return runtime.AuthenticatorFunc(func(params any) (bool, any, error) {
if request, ok := params.(*http.Request); ok {
return handler(request)
@@ -32,7 +32,14 @@ func HttpAuthenticator(handler func(*http.Request) (bool, any, error)) runtime.A
})
}
// ScopedAuthenticator is a function that authenticates a HTTP request against a list of valid scopes.
// HttpAuthenticator aliases [HTTPAuthenticator] for backward-compatibility.
//
// Deprecated: use [HTTPAuthenticator] instead.
func HttpAuthenticator(handler func(*http.Request) (bool, any, error)) runtime.Authenticator { //nolint:revive
return HTTPAuthenticator(handler)
}
// ScopedAuthenticator is a function that authenticates an [http.Request] against a list of valid scopes.
func ScopedAuthenticator(handler func(*ScopedAuthRequest) (bool, any, error)) runtime.Authenticator {
return runtime.AuthenticatorFunc(func(params any) (bool, any, error) {
if request, ok := params.(*ScopedAuthRequest); ok {
@@ -42,22 +49,42 @@ func ScopedAuthenticator(handler func(*ScopedAuthRequest) (bool, any, error)) ru
})
}
// UserPassAuthentication authentication function.
// UserPassAuthentication validates a basic-auth credential.
//
// Implementations comparing the password (or any derived secret) against a
// known value MUST use [crypto/subtle.ConstantTimeCompare]: the runtime
// extracts the credential from the request and delegates the comparison
// here, and does not enforce a constant-time posture on the caller's behalf.
type UserPassAuthentication func(string, string) (any, error)
// UserPassAuthenticationCtx authentication function with [context.Context].
// UserPassAuthenticationCtx is the [context.Context]-aware variant of
// [UserPassAuthentication]. The same constant-time-comparison guidance
// applies.
type UserPassAuthenticationCtx func(context.Context, string, string) (context.Context, any, error)
// TokenAuthentication authentication function.
// TokenAuthentication validates an API-key token.
//
// Implementations comparing the token against a known value MUST use
// [crypto/subtle.ConstantTimeCompare]; the runtime delegates the comparison
// here and does not enforce a constant-time posture on the caller's behalf.
type TokenAuthentication func(string) (any, error)
// TokenAuthenticationCtx authentication function with [context.Context].
// TokenAuthenticationCtx is the [context.Context]-aware variant of
// [TokenAuthentication]. The same constant-time-comparison guidance
// applies.
type TokenAuthenticationCtx func(context.Context, string) (context.Context, any, error)
// ScopedTokenAuthentication authentication function.
// ScopedTokenAuthentication validates a bearer/OAuth2 token along with the
// scopes required for the operation.
//
// Implementations comparing the token against a known value MUST use
// [crypto/subtle.ConstantTimeCompare]; the runtime delegates the comparison
// here and does not enforce a constant-time posture on the caller's behalf.
type ScopedTokenAuthentication func(string, []string) (any, error)
// ScopedTokenAuthenticationCtx authentication function with [context.Context].
// ScopedTokenAuthenticationCtx is the [context.Context]-aware variant of
// [ScopedTokenAuthentication]. The same constant-time-comparison guidance
// applies.
type ScopedTokenAuthenticationCtx func(context.Context, string, []string) (context.Context, any, error)
var DefaultRealmName = "API"
@@ -199,7 +226,7 @@ func APIKeyAuthCtx(name, in string, authenticate TokenAuthenticationCtx) runtime
})
}
// ScopedAuthRequest contains both a [http] request and the required scopes for a particular operation.
// ScopedAuthRequest contains both the [http.Request] and the required scopes for a particular operation.
type ScopedAuthRequest struct {
Request *http.Request
RequiredScopes []string
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+12
View File
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
// Package docui provides standalone HTTP middlewares that serve OpenAPI
// documentation UIs (Swagger UI, ReDoc, RapiDoc) and the spec document
// itself.
//
// The package is stdlib-only and has no transitive dependency on any
// OpenAPI spec, loading or validation library, so it may be imported by
// any net/http application that simply wants to mount a documentation
// site.
package docui
+253
View File
@@ -0,0 +1,253 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"net/http"
"net/url"
"strings"
)
const (
// constants that are common to all UI-serving middlewares.
defaultDocsPath = "docs"
defaultDocsURL = "/swagger.json"
defaultDocsTitle = "API Documentation"
contentTypeHeader = "Content-Type"
applicationJSON = "application/json"
)
// UIMiddleware is a function returning a http middleware which accepts UI [Option].
type UIMiddleware func(...Option) func(http.Handler) http.Handler
// Option to tune your swagger documentation UI middleware.
//
// Options may be combined to alter the route at which the UI asset is served,
// the URL of the spec document, the source URL of the UI asset and the title of the UI page.
//
// The embedded js scriptlet served may be modified using [WithUITemplate].
type Option func(*options)
// SpecOption can be applied to the [ServeSpec] middleware.
type SpecOption func(*specOptions)
// SwaggerUIOptions define a group of extra options specific to the SwaggerUI component.
type SwaggerUIOptions struct {
// OAuth2CallbackURL sets the URL called after OAuth2 login
OAuth2CallbackURL string
// Defines the URL of the swagger UI assets with presets.
//
// Default: https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js
SwaggerPresetURL string
// Defines style sheet URL.
//
// Default: https://unpkg.com/swagger-ui-dist/swagger-ui.css
SwaggerStylesURL string
// Define the favicons URLs.
//
// Defaults:
//
// - 16x16: https://unpkg.com/swagger-ui-dist/favicon-16x16.png
// - 32x32: https://unpkg.com/swagger-ui-dist/favicon-32x32.png
Favicon32 string
Favicon16 string
}
func (o *SwaggerUIOptions) applySwaggerUIDefaults() {
if o.SwaggerPresetURL == "" {
o.SwaggerPresetURL = swaggerPresetLatest
}
if o.SwaggerStylesURL == "" {
o.SwaggerStylesURL = swaggerStylesLatest
}
if o.Favicon16 == "" || o.Favicon32 == "" {
o.Favicon16 = swaggerFavicon16Latest
o.Favicon32 = swaggerFavicon32Latest
}
}
type (
options struct {
SwaggerUIOptions
// BasePath for the UI, defaults to: /
BasePath string
// Path combines with BasePath to construct the path to the UI, defaults to: "docs".
Path string
// SpecURL is the URL of the spec document.
SpecURL string
// Title for the documentation site, default to: API documentation
Title string
// Template specifies a custom template to serve the UI
Template string
// AssetsURL points to the js asset that generates the documentation page.
AssetsURL string
}
specOptions struct {
Path string
Document string
}
)
////////////////////////////////////////////////////////////
// Common UI options
////////////////////////////////////////////////////////////
// WithUIBasePath sets the base path from where to serve the UI assets.
//
// Default: "/"
func WithUIBasePath(base string) Option {
return func(o *options) {
if !strings.HasPrefix(base, "/") {
base = "/" + base
}
o.BasePath = base
}
}
// WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}).
//
// Default: "docs"
func WithUIPath(pth string) Option {
return func(o *options) {
o.Path = pth
}
}
// WithUITitle sets the title of the UI.
//
// Default: "API documentation"
func WithUITitle(title string) Option {
return func(o *options) {
o.Title = title
}
}
// WithUIAssetsURL sets the URL from where to fetch the js assets.
//
// Defaults:
//
// - for Redoc: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js
// - for RapiDoc, this defaults to: https://unpkg.com/rapidoc/dist/rapidoc-min.js
// - for SwaggerUI: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js
func WithUIAssetsURL(assets string) Option {
return func(o *options) {
o.AssetsURL = assets
}
}
// WithUITemplate allows to set a custom template for the UI.
//
// This allows the caller to fully customize the rendered UI, using the advanced options
// provided by any UI.
//
// The UI [middleware] will panic if the template does not parse or execute properly.
//
// Reference documentations to customize your js scriptlet:
//
// - for Redoc: https://github.com/Redocly/redoc/blob/main/docs/deployment/html.md
// - for RapiDoc: https://github.com/rapi-doc/RapiDoc
// - for SwaggerUI: https://github.com/swagger-api/swagger-ui
func WithUITemplate[StringOrBytes ~string | ~[]byte](tpl StringOrBytes) Option {
return func(o *options) {
o.Template = string(tpl)
}
}
// WithSpecURL sets the URL of the spec document.
//
// Defaults to: /swagger.json
func WithSpecURL(u string) Option {
return func(o *options) {
o.SpecURL = u
}
}
////////////////////////////////////////////////////////////
// SwaggerUI UI options
////////////////////////////////////////////////////////////
func WithSwaggerUIOptions(opts SwaggerUIOptions) Option {
return func(o *options) {
o.SwaggerUIOptions = opts
}
}
////////////////////////////////////////////////////////////
// Spec options
////////////////////////////////////////////////////////////
// WithSpecPath sets the path of the spec document.
//
// This is "/swagger.json" by default.
func WithSpecPath(pth string) SpecOption {
return func(o *specOptions) {
if pth == "" {
return
}
o.Path = pth
}
}
// WithSpecPathFromOptions reuses the same SpecPath as the one specified in
// a set of UI [Option] (extract the path from the URL provided by [WithSpecURL]).
func WithSpecPathFromOptions(opts ...Option) SpecOption {
return func(o *specOptions) {
uiOpts := optionsWithDefaults(opts)
// If the spec URL is provided, there is a non-default path to serve the spec.
//
// This makes sure that the UI middleware is aligned with the Spec middleware.
u, _ := url.Parse(uiOpts.SpecURL)
if u.Path == "" {
return
}
o.Path = u.Path
}
}
func optionsWithDefaults(opts []Option, prepend ...Option) options {
o := options{
BasePath: "/",
Path: defaultDocsPath,
SpecURL: defaultDocsURL,
Title: defaultDocsTitle,
}
prepend = append(prepend, opts...)
for _, apply := range prepend {
apply(&o)
}
return o
}
func specOptionsWithDefaults(opts []SpecOption) specOptions {
o := specOptions{
Path: defaultDocsURL,
}
for _, apply := range opts {
apply(&o)
}
if !strings.HasPrefix(o.Path, "/") {
o.Path = "/" + o.Path
}
return o
}
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// UseRapiDoc creates a middleware to serve a documentation site for a swagger spec using [RapidDoc].
//
// [RapiDoc]: https://github.com/rapi-doc/RapiDoc
func UseRapiDoc(opts ...Option) func(next http.Handler) http.Handler {
pth, assets := rapiDocSetup(opts)
return func(next http.Handler) http.Handler {
return serveUI(pth, assets, next)
}
}
// RapiDoc creates a [http.Handler] to serve a documentation site for a swagger spec using [RapidDoc].
//
// By default, the UI is served at route "/docs"
//
// This allows for altering the spec before starting the [http] listener.
//
// [RapiDoc]: https://github.com/rapi-doc/RapiDoc
func RapiDoc(next http.Handler, opts ...Option) http.Handler {
pth, assets := rapiDocSetup(opts)
return serveUI(pth, assets, next)
}
func rapiDocSetup(opts []Option) (pth string, assets []byte) {
o := optionsWithDefaults(opts,
// defaults for rapiDoc
WithUITemplate(rapidocTemplate),
WithUIAssetsURL(rapidocLatest),
)
pth = path.Join(o.BasePath, o.Path)
tmpl := template.Must(template.New("rapidoc").Parse(o.Template))
buf := bytes.NewBuffer(nil)
if err := tmpl.Execute(buf, o); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return pth, buf.Bytes()
}
const (
rapidocLatest = "https://unpkg.com/rapidoc/dist/rapidoc-min.js"
rapidocTemplate = `<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
<script type="module" src="{{ .AssetsURL }}"></script>
</head>
<body>
<rapi-doc spec-url="{{ .SpecURL }}"></rapi-doc>
</body>
</html>
`
)
+82
View File
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// UseRedoc creates a middleware to serve a documentation site for a swagger spec using [Redoc].
//
// [Redoc]: https://redocly.com/docs/redoc
func UseRedoc(opts ...Option) func(next http.Handler) http.Handler {
pth, assets := redocSetup(opts)
return func(next http.Handler) http.Handler {
return serveUI(pth, assets, next)
}
}
// Redoc creates a [http.Handler] to serve a documentation site for a swagger spec using [Redoc].
//
// By default, the UI is served at route "/docs"
//
// This allows for altering the spec before starting the [http] listener.
//
// [Redoc]: https://redocly.com/docs/redoc
func Redoc(next http.Handler, opts ...Option) http.Handler {
pth, assets := redocSetup(opts)
return serveUI(pth, assets, next)
}
func redocSetup(opts []Option) (pth string, assets []byte) {
o := optionsWithDefaults(opts,
// defaults for redoc
WithUITemplate(redocTemplate),
WithUIAssetsURL(redocLatest),
)
pth = path.Join(o.BasePath, o.Path)
tmpl := template.Must(template.New("redoc").Parse(o.Template))
buf := bytes.NewBuffer(nil)
if err := tmpl.Execute(buf, o); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return pth, buf.Bytes()
}
const (
redocLatest = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" // "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"
redocTemplate = `<!DOCTYPE html>
<html>
<head>
<title>{{ .Title }}</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='{{ .SpecURL }}'></redoc>
<script src="{{ .AssetsURL }}"> </script>
</body>
</html>
`
)
+33
View File
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"fmt"
"net/http"
"path"
)
// serveUI creates a [http.Handler] that serves a templated asset as text/html.
func serveUI(pth string, assets []byte, next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path.Clean(r.URL.Path) == pth {
rw.Header().Set(contentTypeHeader, "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(assets)
return
}
if next != nil {
next.ServeHTTP(rw, r)
return
}
rw.Header().Set(contentTypeHeader, "text/plain")
rw.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprintf(rw, "%q not found", pth)
})
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"net/http"
"path"
)
// UseSpec creates a middleware to serve a swagger spec as a JSON document.
func UseSpec(spec []byte, opts ...SpecOption) func(next http.Handler) http.Handler {
o := specOptionsWithDefaults(opts)
return func(next http.Handler) http.Handler {
return handleSpec(o.Path, spec, next)
}
}
// ServeSpec creates a [http.Handler] to serve a swagger spec as a JSON document.
//
// This allows for altering the spec before starting the [http] listener.
//
// Additional [SpecOption] can be used to change the path and the name of the document (defaults to "/swagger.json").
func ServeSpec(spec []byte, next http.Handler, opts ...SpecOption) http.Handler {
o := specOptionsWithDefaults(opts)
return handleSpec(o.Path, spec, next)
}
func handleSpec(pth string, spec []byte, next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path.Clean(r.URL.Path) == pth {
rw.Header().Set(contentTypeHeader, applicationJSON)
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(spec)
return
}
if next != nil {
next.ServeHTTP(rw, r)
return
}
rw.Header().Set(contentTypeHeader, applicationJSON)
rw.WriteHeader(http.StatusNotFound)
})
}
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package docui
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path"
)
// UseSwaggerUI creates a middleware to serve a documentation site for a swagger spec using [SwaggerUI].
//
// [SwaggerUI]: https://swagger.io/tools/swagger-ui
func UseSwaggerUI(opts ...Option) func(next http.Handler) http.Handler {
pth, assets := swaggeruiSetup(opts)
return func(next http.Handler) http.Handler {
return serveUI(pth, assets, next)
}
}
// SwaggerUI creates a [http.Handler] to serve a documentation site for a swagger spec using [SwaggerUI].
//
// By default, the UI is served at route "/docs"
//
// This allows for altering the spec before starting the [http] listener.
//
// [SwaggerUI]: https://swagger.io/tools/swagger-ui
func SwaggerUI(next http.Handler, opts ...Option) http.Handler {
pth, assets := swaggeruiSetup(opts)
return serveUI(pth, assets, next)
}
func swaggeruiSetup(opts []Option) (pth string, assets []byte) {
o := optionsWithDefaults(opts,
// defaults for SwaggerUI
WithUITemplate(swaggeruiTemplate),
WithUIAssetsURL(swaggerLatest),
)
o.applySwaggerUIDefaults()
if o.OAuth2CallbackURL == "" {
o.OAuth2CallbackURL = path.Join(o.BasePath, o.Path, "oauth2-callback")
}
pth = path.Join(o.BasePath, o.Path)
tmpl := template.Must(template.New("swaggerui").Parse(o.Template))
buf := bytes.NewBuffer(nil)
if err := tmpl.Execute(buf, o); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return pth, buf.Bytes()
}
const (
swaggerLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"
swaggerPresetLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"
swaggerStylesLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui.css"
swaggerFavicon32Latest = "https://unpkg.com/swagger-ui-dist/favicon-32x32.png"
swaggerFavicon16Latest = "https://unpkg.com/swagger-ui-dist/favicon-16x16.png"
swaggeruiTemplate = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ .Title }}</title>
{{- if .SwaggerStylesURL }}
<link rel="stylesheet" type="text/css" href="{{ .SwaggerStylesURL }}" />
{{- end }}
{{- if .Favicon32 }}
<link rel="icon" type="image/png" href="{{ .Favicon32 }}" sizes="32x32" />
{{- end }}
{{- if .Favicon16 }}
<link rel="icon" type="image/png" href="{{ .Favicon16 }}" sizes="16x16" />
{{- end }}
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{{ .AssetsURL }}"> </script>
{{- if .SwaggerPresetURL }}
<script src="{{ .SwaggerPresetURL }}"> </script>
{{- end }}
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: '{{ .SpecURL }}',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
{{- if .OAuth2CallbackURL }}
oauth2RedirectUrl: '{{ .OAuth2CallbackURL }}'
{{- end }}
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>
`
)
@@ -1,30 +1,56 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package middleware
package docui
import (
"bytes"
"fmt"
"net/http"
"path"
"text/template"
)
func SwaggerUIOAuth2Callback(opts SwaggerUIOpts, next http.Handler) http.Handler {
opts.EnsureDefaultsOauth2()
// UseSwaggerUIOAuth2Callback creates a middleware that serves a callback URL to complete
// a OAuth2 token handshake.
func UseSwaggerUIOAuth2Callback(opts ...Option) func(next http.Handler) http.Handler {
pth, assets := swaggeruiOAuth2Setup(opts)
pth := opts.OAuthCallbackURL
tmpl := template.Must(template.New("swaggeroauth").Parse(opts.Template))
assets := bytes.NewBuffer(nil)
if err := tmpl.Execute(assets, opts); err != nil {
return func(next http.Handler) http.Handler {
return serveUI(pth, assets, next)
}
}
// SwaggerUIOAuth2Callback creates a [http.Handler] that serves a callback URL to complete
// a OAuth2 token handshake.
func SwaggerUIOAuth2Callback(next http.Handler, opts ...Option) http.Handler {
pth, assets := swaggeruiOAuth2Setup(opts)
return serveUI(pth, assets, next)
}
func swaggeruiOAuth2Setup(opts []Option) (pth string, assets []byte) {
o := optionsWithDefaults(opts,
// defaults for SwaggerUI OAuth2 callback endpoint
WithUITemplate(swaggerOAuth2Template),
WithUIAssetsURL(swaggerLatest),
)
o.applySwaggerUIDefaults()
if o.OAuth2CallbackURL == "" {
o.OAuth2CallbackURL = path.Join(o.BasePath, o.Path, "oauth2-callback")
}
pth = o.OAuth2CallbackURL
tmpl := template.Must(template.New("swaggeroauth2").Parse(o.Template))
buf := bytes.NewBuffer(nil)
if err := tmpl.Execute(buf, o); err != nil {
panic(fmt.Errorf("cannot execute template: %w", err))
}
return serveUI(pth, assets.Bytes(), next)
return pth, buf.Bytes()
}
const (
swaggerOAuthTemplate = `
const swaggerOAuth2Template = `
<!DOCTYPE html>
<html lang="en">
<head>
@@ -105,4 +131,3 @@ const (
</body>
</html>
`
)
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
// Package mediatype provides a typed value for media types
// defined by RFC 7231 and RFC 2045.
//
// The matching/selection primitives used by both server-side
// validation and Accept-header negotiation.
//
// The package is stdlib-only.
//
// # The matching rule
//
// [MediaType.Matches] is asymmetric. The receiver acts as the "bound"
// (an allowed entry on the server side, or a candidate offer when
// matching against an Accept entry); the argument is the constraint
// (the actual incoming request, or the Accept entry being satisfied).
//
// - bare type/subtype must agree, with wildcard handling on either
// side ("*/*" matches anything; "type/*" matches any subtype);
// - if the receiver carries no parameters, any constraint is
// accepted regardless of its parameters;
// - otherwise every (key,value) pair on the constraint must be
// present on the receiver, with case-insensitive value
// comparison. The receiver may carry additional parameters the
// constraint does not list.
//
// q-values are NOT considered by [MediaType.Matches] — they are the
// negotiator's concern, handled inside [Set.BestMatch].
package mediatype
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package mediatype
// Lookup finds the entry in m matching mediaType, with alias-aware
// fallback. It is the canonical seam for codec-map lookups in both
// the client and server runtimes — placing the fallback policy here
// keeps alias definitions (and any future lookup tolerances) in one
// place.
//
// Lookup tries the following, in order, returning the first hit:
//
// 1. mediaType verbatim (fast path for callers that already pass a
// canonical, parameter-free string and store map keys in the
// same form).
// 2. An alias-aware walk against the parsed "type/subtype" form:
// a direct map hit on the parsed key, on its alias canonical
// if any, and finally an O(len(m)) scan returning any map
// entry whose key alias-canonicalizes to the same target.
// Catches both "map keyed by canonical, query uses alias" and
// "map keyed by one alias, query uses another alias of the
// same canonical".
// 3. When [AllowSuffix] is passed in opts: the same alias-aware
// walk against the RFC 6839 structured-syntax suffix base.
// Catches the "spec/traffic divergence" case (request for
// application/vnd.api+json finds a JSON consumer registered
// under application/json). Query-side suffix fold only — no
// map-side suffix folding.
//
// Lookup does NOT fall back to "*/*". Callers that want wildcard
// behavior (the historical resolveConsumer pattern in the client
// runtime) chain that themselves after a Lookup miss — keeping
// wildcard semantics explicit at each call site.
//
// Map keys are expected in canonical "type/subtype" form (no
// parameters). The runtime's default Consumers / Producers maps
// follow this convention.
//
// Returns (zero, false) when:
//
// - m is empty;
// - mediaType fails to parse and is not present verbatim;
// - none of the active steps hits.
//
// The malformed-vs-not-found distinction is intentionally elided:
// codec-lookup callers treat both as the same "no codec" error path.
func Lookup[T any](m map[string]T, mediaType string, opts ...MatchOption) (T, bool) {
var zero T
if len(m) == 0 {
return zero, false
}
o := applyMatchOptions(opts)
// Fast path: raw key (preserves any caller behaviour that stored
// non-canonical strings as map keys, and skips parsing in the
// common already-canonical case).
if v, ok := m[mediaType]; ok {
return v, true
}
mt, err := Parse(mediaType)
if err != nil {
return zero, false
}
key := mt.Type + "/" + mt.Subtype
if v, ok := findByCanonical(m, key); ok {
return v, true
}
if o.allowSuffix && mt.Suffix != "" {
base := mt.Base()
if baseKey := base.Type + "/" + base.Subtype; baseKey != key {
if v, ok := findByCanonical(m, baseKey); ok {
return v, true
}
}
}
return zero, false
}
// findByCanonical returns the first entry in m whose key
// alias-canonicalizes to the same value as target.
//
// Tries direct hits before the O(len(m)) walk:
//
// 1. m[target] — map keyed by the same string.
// 2. m[aliases[target]] — map keyed by the canonical when target
// is an alias.
// 3. Walk m: return any entry where canonical(k) == canonical(target).
// Catches the "map keyed by an alias different from the query
// side" case (e.g. registered under text/yaml, queried as
// application/x-yaml — both canonicalize to application/yaml).
//
// Map size is single-digit for the runtime's codec maps, so the
// walk is negligible.
func findByCanonical[T any](m map[string]T, target string) (T, bool) {
if v, ok := m[target]; ok {
return v, true
}
canonTarget := target
if canon, ok := aliases[target]; ok {
canonTarget = canon
if v, ok := m[canonTarget]; ok {
return v, true
}
}
for k, v := range m {
kCanon := k
if c, ok := aliases[k]; ok {
kCanon = c
}
if kCanon == canonTarget {
return v, true
}
}
var zero T
return zero, false
}
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package mediatype
// MatchFirst reports whether actual matches any entry in allowed,
// using [MediaType.Match] — the param-aware RFC 7231 rule plus the
// alias bridge from the package-internal alias table.
//
// The scan is multi-pass and tier-ordered: the first pass returns
// the first allowed entry that matches under [MatchExact] (RFC 7231
// semantics); the second pass looks for a [MatchAlias] match; when
// [AllowSuffix] is in opts a third pass looks for a [MatchSuffix]
// match. This preserves the "stronger tier wins" ordering from
// [Set.BestMatch] while keeping the "first match wins" semantics
// within each tier.
//
// Return values:
//
// - (matched, true, nil) — the first allowed entry that
// matches, with exact matches preferred over alias matches.
// - (zero, false, nil) — actual is well-formed but no
// allowed entry accepts it. Maps to an HTTP 415 outcome.
// - (zero, false, err) — actual fails to parse. err
// wraps [ErrMalformed], so callers can use [errors.Is] to
// distinguish this case. Maps to an HTTP 400 outcome.
//
// Allowed entries that themselves fail to parse are skipped (they
// cannot match any well-formed actual), and no error is surfaced
// for them.
//
// An empty allowed list returns (zero, false, nil). MatchFirst is
// the primitive; callers decide what no-constraints means in their
// context.
func MatchFirst(allowed []string, actual string, opts ...MatchOption) (MediaType, bool, error) {
if len(allowed) == 0 {
return MediaType{}, false, nil
}
actualMT, err := Parse(actual)
if err != nil {
return MediaType{}, false, err
}
o := applyMatchOptions(opts)
// Tier-ordered passes over the allowed list. The list is
// typically short (an operation's Consumes set), so re-parsing
// each entry on every pass is cheaper than caching parses across
// passes.
tiers := []MatchKind{MatchExact, MatchAlias}
if o.allowSuffix {
tiers = append(tiers, MatchSuffix)
}
for _, want := range tiers {
for _, a := range allowed {
allowedMT, perr := Parse(a)
if perr != nil {
continue
}
if allowedMT.Match(actualMT) == want {
return allowedMT, true, nil
}
}
}
return MediaType{}, false, nil
}
@@ -0,0 +1,379 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package mediatype
import (
"fmt"
"mime"
"strconv"
"strings"
)
const wildcard = "*"
// Internal constants for the suffixBase table and any future
// in-package references to the well-known base media types.
const (
typeApplication = "application"
subtypeJSON = "json"
subtypeXML = "xml"
subtypeYAML = "yaml"
mtYAML = typeApplication + "/" + subtypeYAML
)
// Specificity scores returned by [MediaType.Specificity], ordered from
// least to most specific.
const (
SpecificityAny = iota // "*/*"
SpecificityType // "type/*"
SpecificityExact // "type/subtype" (no params)
SpecificityExactWithParams // "type/subtype;k=v"
)
// MatchKind classifies the strength of a match between two media
// types. Larger values represent stronger matches and win in
// negotiation tie-breaks.
//
// MatchExact covers direct subtype or wildcard agreement under RFC
// 7231 rules; MatchAlias is returned when the strict comparison
// fails but the two values agree after canonicalization through the
// internal alias table (see [MediaType.Canonical]); MatchSuffix is
// returned only when both alias and exact comparisons fail but the
// two values agree after folding the RFC 6839 structured-syntax
// suffix (see [MediaType.Base]).
//
// MatchSuffix matches are off by default at the negotiation /
// lookup callers — they count only when [AllowSuffix] is passed to
// [Set.BestMatch], [MatchFirst], or [Lookup]. The opt-in is the
// single user-visible knob; [MediaType.Match] itself always returns
// the strongest tier that succeeds.
type MatchKind int
// MatchKind values. Returned by [MediaType.Match].
const (
MatchNone MatchKind = iota // no match
MatchSuffix // matched via the RFC 6839 suffix base
MatchAlias // matched via the alias table
MatchExact // matched directly (RFC 7231 semantics)
)
// MatchOption configures the matching tolerances used by
// [Set.BestMatch], [MatchFirst], and [Lookup]. The zero behaviour
// is strict: only [MatchAlias] and [MatchExact] count.
type MatchOption func(*matchOptions)
type matchOptions struct {
allowSuffix bool
}
func applyMatchOptions(opts []MatchOption) matchOptions {
var o matchOptions
for _, opt := range opts {
opt(&o)
}
return o
}
// AllowSuffix returns a [MatchOption] that lets the caller count
// [MatchSuffix] results as valid matches. Use this to opt into
// RFC 6839 structured-syntax suffix tolerance for situations where
// the client/server traffic does not strictly abide by the spec
// (typical example: server returning application/problem+json
// against operations that only declare application/json in
// produces).
func AllowSuffix() MatchOption {
return func(o *matchOptions) {
o.allowSuffix = true
}
}
type mediaTypeError string
func (e mediaTypeError) Error() string {
return string(e)
}
// ErrMalformed is the sentinel returned (wrapped) by [Parse] when its input
// cannot be parsed as an RFC 7231 media type.
//
// Callers can test for it with [errors.Is] to distinguish a client-side
// malformed Content-Type header (an HTTP 400 outcome) from a well-formed
// value that simply matches no allowed entry (an HTTP 415 outcome).
const ErrMalformed mediaTypeError = "mediatype: malformed"
// MediaType is a parsed RFC 7231 media type with optional parameters and
// an optional q-value (used by Accept negotiation).
//
// Type, Subtype and the keys of Params are lowercased. Parameter values
// are preserved verbatim; comparisons are case-insensitive (matching the
// pre-v0.30 behaviour and the common convention for charset, version, etc.).
//
// Suffix exposes the RFC 6839 structured syntax suffix (the token after
// the final '+' in Subtype) as a parallel hint. Subtype itself retains
// the full wire value, so existing callers comparing Subtype against a
// string see no change.
type MediaType struct {
Type string
Subtype string
Suffix string
Params map[string]string
Q float64
}
// suffixBase maps a known RFC 6839 / RFC 9512 structured syntax
// suffix (without the leading '+', lowercased) to its base media
// type. It is the authoritative table consulted by [MediaType.Base].
//
// The table is intentionally small: only suffixes whose base type
// has a codec in the default runtime maps are listed. CBOR, zip,
// BER, DER, FastInfoset and WBXML are registered by IANA but have
// no default codec in this runtime; adding them is gated on having
// something to do with them.
//
// Package-internal by design: the external API is [MediaType.Base].
// If users ever need to extend the table, a Register-style function
// is the right answer, not an exported mutable map.
var suffixBase = map[string]MediaType{
subtypeJSON: {Type: typeApplication, Subtype: subtypeJSON},
subtypeXML: {Type: typeApplication, Subtype: subtypeXML},
subtypeYAML: {Type: typeApplication, Subtype: subtypeYAML},
}
// aliases maps a deprecated or legacy media-type name to its
// canonical registered equivalent. Keys are the lowercased
// "type/subtype" form with no parameters; values are the canonical
// "type/subtype" form, also without parameters.
//
// Entries are limited to media types whose authoritative RFC
// explicitly names the alias. The seed entries cite RFC 9512 §2.1,
// which enumerates "Deprecated alias names for this type:
// application/x-yaml, text/yaml, and text/x-yaml" as part of the
// IANA registration template for application/yaml.
//
// Pull requests adding entries need an analogous citation in the
// commit message; entries without authoritative backing belong in
// caller-side canonicalization, not here.
//
// Package-internal by design: the external API is
// [MediaType.Canonical] and [MediaType.Match]. If users ever need
// to register their own aliases, a Register-style function is the
// right answer, not an exported mutable map.
var aliases = map[string]string{
"application/x-yaml": mtYAML, // RFC 9512 §2.1
"text/yaml": mtYAML, // RFC 9512 §2.1
"text/x-yaml": mtYAML, // RFC 9512 §2.1
}
// Parse parses a single media type. The input may carry parameters and a
// q-value; the q-value is extracted into [MediaType.Q] and removed from
// [MediaType.Params].
//
// An empty input returns an error.
func Parse(s string) (MediaType, error) {
s = strings.TrimSpace(s)
if s == "" {
return MediaType{}, fmt.Errorf("%w: empty value", ErrMalformed)
}
full, params, err := mime.ParseMediaType(s)
if err != nil {
return MediaType{}, fmt.Errorf("%w: %w", ErrMalformed, err)
}
slash := strings.IndexByte(full, '/')
if slash <= 0 || slash == len(full)-1 {
return MediaType{}, fmt.Errorf("%w: %q has no subtype", ErrMalformed, s)
}
mt := MediaType{
Type: full[:slash],
Subtype: full[slash+1:],
Q: 1.0,
}
// RFC 6839: structured syntax suffix is the trailing '+'-delimited
// token of the subtype. Only the last '+' counts ("foo+bar+json" →
// suffix "json"). A trailing '+' with nothing after it is not a
// valid suffix and is ignored. mime.ParseMediaType has already
// lowercased the subtype, so no further ToLower is needed.
if plus := strings.LastIndexByte(mt.Subtype, '+'); plus >= 0 && plus < len(mt.Subtype)-1 {
mt.Suffix = mt.Subtype[plus+1:]
}
if q, ok := params["q"]; ok {
if qf, perr := strconv.ParseFloat(q, 64); perr == nil {
if qf < 0 {
qf = 0
}
if qf > 1 {
qf = 1
}
mt.Q = qf
}
delete(params, "q")
}
if len(params) > 0 {
mt.Params = params
}
return mt, nil
}
// String renders the canonical "type/subtype;k=v;k=v" form. Parameters are
// emitted in lexicographic key order (the standard library guarantees this)
// so the result is stable. The q-value is NOT emitted — it is meta, not
// part of the media type identity.
func (m MediaType) String() string {
if m.Type == "" && m.Subtype == "" {
return ""
}
return mime.FormatMediaType(m.Type+"/"+m.Subtype, m.Params)
}
// Matches reports whether the receiver accepts other, per the package
// documentation: the receiver is the bound, other is the constraint.
func (m MediaType) Matches(other MediaType) bool {
if !typeAgrees(m.Type, other.Type) {
return false
}
if !subtypeAgrees(m.Type, m.Subtype, other.Type, other.Subtype) {
return false
}
if len(m.Params) == 0 {
return true
}
for k, v := range other.Params {
sv, ok := m.Params[k]
if !ok || !strings.EqualFold(sv, v) {
return false
}
}
return true
}
// Specificity returns a numeric score for ordering matches. Higher is more
// specific. The returned value is one of [SpecificityAny],
// [SpecificityType], [SpecificityExact] or [SpecificityExactWithParams].
func (m MediaType) Specificity() int {
if m.Type == wildcard && m.Subtype == wildcard {
return SpecificityAny
}
if m.Subtype == wildcard {
return SpecificityType
}
if len(m.Params) == 0 {
return SpecificityExact
}
return SpecificityExactWithParams
}
// typeAgrees reports whether two top-level types match, allowing "*" on
// either side. A type of "*" without a "*" subtype is rejected per RFC
// 7231 §5.3.2 ("*/sub" is not valid), but Parse never produces such a
// shape — it goes through mime.ParseMediaType.
func typeAgrees(a, b string) bool {
return a == wildcard || b == wildcard || a == b
}
// subtypeAgrees handles the "type/*" wildcard: the bare type must match
// (a "*/*" pair has already been accepted by typeAgrees above).
func subtypeAgrees(at, asub, bt, bsub string) bool {
if at == wildcard || bt == wildcard {
// at least one side is "*/*" or "*/sub". With typeAgrees having
// returned true, we accept.
return true
}
if asub == wildcard || bsub == wildcard {
return true
}
return asub == bsub
}
// StripParams returns a copy of m with no parameters. Q is preserved
// because it drives negotiation ordering, not media-type identity.
//
// Useful for the legacy "ignore parameters" negotiation mode.
func (m MediaType) StripParams() MediaType {
return MediaType{Type: m.Type, Subtype: m.Subtype, Suffix: m.Suffix, Q: m.Q}
}
// Base returns the base media type implied by the RFC 6839 structured
// syntax suffix, or m unchanged when:
//
// - Suffix is empty;
// - Suffix is not present in the package-internal suffix→base table.
//
// The returned value represents the structural base only: it carries
// no parameters and no q-value. Use it to find a codec for the
// underlying wire format — for example, "application/vnd.api+json"
// resolves to "application/json".
//
// Base does not mutate the receiver.
func (m MediaType) Base() MediaType {
if m.Suffix == "" {
return m
}
base, ok := suffixBase[m.Suffix]
if !ok {
return m
}
return base
}
// Canonical returns m rewritten to its canonical media type via
// the package-internal alias table, or m unchanged when
// (Type, Subtype) is not a known alias. Params and Q are preserved on the returned value; Suffix
// is recomputed from the canonical Subtype (none of the current
// entries carry a suffix, but the contract is forward-safe).
//
// Canonical does not mutate the receiver.
func (m MediaType) Canonical() MediaType {
key := m.Type + "/" + m.Subtype
canon, ok := aliases[key]
if !ok {
return m
}
slash := strings.IndexByte(canon, '/')
out := m
out.Type = canon[:slash]
out.Subtype = canon[slash+1:]
out.Suffix = ""
if plus := strings.LastIndexByte(out.Subtype, '+'); plus >= 0 && plus < len(out.Subtype)-1 {
out.Suffix = out.Subtype[plus+1:]
}
return out
}
// Match reports how m matches other, classifying the result by
// [MatchKind]. Used by negotiation to rank candidate offers:
// stronger tiers win when both apply.
//
// Returns, strongest first:
//
// - MatchExact when m.Matches(other) is true under the strict
// RFC 7231 rules (including wildcards and the param subset
// rule).
// - MatchAlias when m.Canonical().Matches(other.Canonical())
// is true but the strict comparison failed.
// - MatchSuffix when m.Base().Canonical().Matches(
// other.Base().Canonical()) is true but the alias comparison
// failed (RFC 6839 structured-syntax suffix fold).
// - MatchNone otherwise.
//
// The asymmetric "bound vs constraint" rule of [MediaType.Matches]
// is preserved at every tier. Match itself is always lenient — the
// opt-in to count MatchSuffix lives one level up at [Set.BestMatch],
// [MatchFirst], and [Lookup] via [AllowSuffix].
func (m MediaType) Match(other MediaType) MatchKind {
if m.Matches(other) {
return MatchExact
}
if m.Canonical().Matches(other.Canonical()) {
return MatchAlias
}
if m.Base().Canonical().Matches(other.Base().Canonical()) {
return MatchSuffix
}
return MatchNone
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package mediatype
import (
"strings"
)
// Set is a list of media types — typically the parsed value of an Accept
// header, or a list of server-side offers.
type Set []MediaType
// ParseAccept parses a comma-separated list of media types, as found in
// the Accept, Accept-Charset (etc.) HTTP headers. Malformed entries are
// skipped silently — be liberal in what you accept.
//
// An empty input returns nil.
func ParseAccept(s string) Set {
parts := splitTopLevel(s)
if len(parts) == 0 {
return nil
}
out := make(Set, 0, len(parts))
for _, p := range parts {
mt, err := Parse(p)
if err != nil {
continue
}
out = append(out, mt)
}
return out
}
// BestMatch picks the offer most acceptable to the receiver's Accept
// entries. Selection follows RFC 7231 §5.3.2 plus tier-aware
// ranking:
//
// - highest q-value wins;
// - ties on q broken by the highest [MediaType.Specificity] of the
// matching Accept entry;
// - ties on specificity broken by [MatchKind] (MatchExact beats
// MatchAlias beats MatchSuffix);
// - ties on match kind broken by earliest position in offered.
//
// Accept entries with q=0 are treated as exclusions and never match.
// MatchSuffix results are only counted when [AllowSuffix] is passed.
// Returns ok=false if no offer matched any non-zero-q entry.
func (s Set) BestMatch(offered Set, opts ...MatchOption) (best MediaType, ok bool) {
if len(s) == 0 || len(offered) == 0 {
return MediaType{}, false
}
o := applyMatchOptions(opts)
bestQ := -1.0
bestSpec := -1
bestKind := MatchNone
bestIdx := -1
for i, offer := range offered {
for _, entry := range s {
if entry.Q == 0 {
continue
}
kind := offer.Match(entry)
if kind == MatchNone {
continue
}
if kind == MatchSuffix && !o.allowSuffix {
continue
}
spec := entry.Specificity()
switch {
case entry.Q > bestQ:
best, ok = offer, true
bestQ = entry.Q
bestSpec = spec
bestKind = kind
bestIdx = i
case entry.Q < bestQ:
// not better
case spec > bestSpec:
best, ok = offer, true
bestSpec = spec
bestKind = kind
bestIdx = i
case spec < bestSpec:
// not better
case kind > bestKind:
best, ok = offer, true
bestKind = kind
bestIdx = i
case kind < bestKind:
// not better
case bestIdx < 0 || i < bestIdx:
best, ok = offer, true
bestIdx = i
}
}
}
return best, ok
}
// splitTopLevel splits s on top-level commas, respecting double-quoted
// strings (RFC 7230 §3.2.6 — quoted-string).
func splitTopLevel(s string) []string {
if strings.IndexByte(s, ',') < 0 {
if t := strings.TrimSpace(s); t != "" {
return []string{t}
}
return nil
}
var out []string
start := 0
inQuote := false
escape := false
for i := range len(s) {
c := s[i]
switch {
case escape:
escape = false
case inQuote && c == '\\':
escape = true
case c == '"':
inQuote = !inQuote
case c == ',' && !inQuote:
if t := strings.TrimSpace(s[start:i]); t != "" {
out = append(out, t)
}
start = i + 1
}
}
if t := strings.TrimSpace(s[start:]); t != "" {
out = append(out, t)
}
return out
}
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
// Package negotiate provides server-side HTTP content negotiation
// helpers — selecting the response Content-Type from an Accept header
// and the response Content-Encoding from an Accept-Encoding header.
//
// The package is stdlib-only (modulo the typed [mediatype.MediaType]
// values it consumes).
//
// The exported [ContentType] honours MIME-type parameters by default;
// use [WithIgnoreParameters] to restore the pre-v0.30 looser match.
package negotiate
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package negotiate
import (
"net/http"
"strings"
"github.com/go-openapi/runtime/server-middleware/mediatype"
"github.com/go-openapi/runtime/server-middleware/negotiate/header"
)
// Option configures [ContentType] behaviour.
type Option func(*options)
type options struct {
ignoreParameters bool
matchSuffix bool
}
func optionsWithDefaults(opts []Option) options {
var o options
for _, apply := range opts {
apply(&o)
}
return o
}
// WithIgnoreParameters returns an [Option] that strips MIME-type
// parameters from both Accept entries and offers before matching, restoring
// the behaviour the runtime had before v0.30.
//
// New code should leave parameters honoured (the default). This option
// exists for applications that depend on the looser pre-v0.30 match —
// most often because their producers and Accept clients use mismatched
// charset or version params that they treat as informational.
//
// Example — per-call opt-out:
//
// chosen := negotiate.ContentType(r, offers, "",
// negotiate.WithIgnoreParameters(true),
// )
//
// Example — server-wide opt-out (via [middleware.Context]):
//
// ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true)
func WithIgnoreParameters(ignore bool) Option {
return func(o *options) {
o.ignoreParameters = ignore
}
}
// WithMatchSuffix returns an [Option] that extends content
// negotiation to tolerate RFC 6839 structured-syntax suffix media
// types. When enabled, an Accept entry of "application/json"
// matches an offer of "application/vnd.api+json" and vice versa,
// for the suffixes recognised by the runtime (+json, +xml, +yaml).
//
// Default: strict (false). Use only when interoperating with
// clients or servers that do not strictly abide by the spec — for
// example, servers returning application/problem+json error
// responses against operations that only declare application/json
// in produces.
//
// Suffix matches always lose to exact and alias matches when those
// are on offer; see [mediatype.MatchKind] for the tier ordering.
//
// Example — per-call opt-in:
//
// chosen := negotiate.ContentType(r, offers, "",
// negotiate.WithMatchSuffix(true),
// )
//
// Example — server-wide opt-in (via [middleware.Context]):
//
// ctx := middleware.NewContext(spec, api, nil).SetMatchSuffix(true)
func WithMatchSuffix(enable bool) Option {
return func(o *options) {
o.matchSuffix = enable
}
}
// ContentEncoding returns the best offered content encoding for the
// request's Accept-Encoding header. If two offers match with equal
// weight then the offer earlier in the list is preferred. If no offers
// are acceptable, then "" is returned.
//
// Encoding tokens have no parameters, so this function is unaffected by
// the v0.30 parameter-honouring change to [ContentType].
func ContentEncoding(r *http.Request, offers []string) string {
bestOffer := "identity"
bestQ := -1.0
specs := header.ParseAccept(r.Header, "Accept-Encoding")
for _, offer := range offers {
for _, spec := range specs {
if spec.Q > bestQ &&
(spec.Value == "*" || spec.Value == offer) {
bestQ = spec.Q
bestOffer = offer
}
}
}
if bestQ == 0 {
bestOffer = ""
}
return bestOffer
}
// ContentType returns the best offered content type for the request's
// Accept header. If two offers match with equal weight, then the more
// specific offer is preferred (text/* trumps */*; type/subtype trumps
// type/*). If two offers match with equal weight and specificity, then
// the offer earlier in the list is preferred. If no offers match, then
// defaultOffer is returned.
//
// As of v0.30 the matching rule honours MIME-type parameters: an Accept
// entry of "text/plain;charset=utf-8" matches an offer of bare
// "text/plain" (offer carries no constraint), but it does NOT match an
// offer of "text/plain;charset=ascii" (charset values disagree). Pass
// [WithIgnoreParameters](true) to restore the pre-v0.30 behaviour where
// parameters were stripped before matching — see [WithIgnoreParameters]
// for details and an example.
//
// When the Accept header is absent, the first offer is returned
// unchanged (param-stripping is irrelevant in that case).
func ContentType(r *http.Request, offers []string, defaultOffer string, opts ...Option) string {
if len(offers) == 0 {
return defaultOffer
}
o := optionsWithDefaults(opts)
// Per RFC 7230 §3.2.2, multiple Accept headers are equivalent to a
// single comma-joined value. Join before parsing so we don't drop
// later entries.
acceptValues := r.Header.Values("Accept")
if len(acceptValues) == 0 {
return offers[0]
}
acceptSet := mediatype.ParseAccept(strings.Join(acceptValues, ", "))
if len(acceptSet) == 0 {
return defaultOffer
}
offerSet := make(mediatype.Set, 0, len(offers))
rawByIdx := make([]string, 0, len(offers))
for _, raw := range offers {
mt, err := mediatype.Parse(raw)
if err != nil {
continue
}
offerSet = append(offerSet, mt)
rawByIdx = append(rawByIdx, raw)
}
if len(offerSet) == 0 {
return defaultOffer
}
if o.ignoreParameters {
acceptSet = stripSet(acceptSet)
offerSet = stripSet(offerSet)
}
var matchOpts []mediatype.MatchOption
if o.matchSuffix {
matchOpts = append(matchOpts, mediatype.AllowSuffix())
}
best, ok := acceptSet.BestMatch(offerSet, matchOpts...)
if !ok {
return defaultOffer
}
// Return the original raw offer string so callers receive the value
// they declared, with its parameters preserved.
for i, mt := range offerSet {
if mt.Type == best.Type && mt.Subtype == best.Subtype && sameParams(mt.Params, best.Params) {
return rawByIdx[i]
}
}
return best.String()
}
func stripSet(s mediatype.Set) mediatype.Set {
out := make(mediatype.Set, len(s))
for i, m := range s {
out[i] = m.StripParams()
}
return out
}
func sameParams(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
+1 -1
View File
@@ -60,7 +60,7 @@ var Statuses = map[int]string{
444: "No Response",
449: "Retry With",
450: "Blocked by Windows Parental Controls",
451: "Wrong Exchange Server",
451: "Unavailable For Legal Reasons",
499: "Client Closed Request",
500: "Internal Server Error",
501: "Not Implemented",
+3 -3
View File
@@ -36,14 +36,14 @@ func TextConsumer() Consumer {
if tu, ok := data.(encoding.TextUnmarshaler); ok {
err := tu.UnmarshalText(b)
if err != nil {
return fmt.Errorf("text consumer: %v", err)
return fmt.Errorf("text consumer: %w", err)
}
return nil
}
t := reflect.TypeOf(data)
if data != nil && t.Kind() == reflect.Ptr {
if data != nil && t.Kind() == reflect.Pointer {
v := reflect.Indirect(reflect.ValueOf(data))
if t.Elem().Kind() == reflect.String {
v.SetString(string(b))
@@ -70,7 +70,7 @@ func TextProducer() Producer {
if tm, ok := data.(encoding.TextMarshaler); ok {
txt, err := tm.MarshalText()
if err != nil {
return fmt.Errorf("text producer: %v", err)
return fmt.Errorf("text producer: %w", err)
}
_, err = writer.Write(txt)
return err
+2 -1
View File
@@ -6,8 +6,9 @@ package yamlpc
import (
"io"
"github.com/go-openapi/runtime"
yaml "go.yaml.in/yaml/v3"
"github.com/go-openapi/runtime"
)
// YAMLConsumer creates a consumer for [yaml] data.
-1
View File
@@ -3,4 +3,3 @@
.idea
.env
.mcp.json
.claude/
+3 -3
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 38 | 392 |
| 38 | 396 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 191 | <https://github.com/go-openapi/spec/commits?author=casualjim> |
| @fredbi | 90 | <https://github.com/go-openapi/spec/commits?author=fredbi> |
| @fredbi | 94 | <https://github.com/go-openapi/spec/commits?author=fredbi> |
| @pytlesk4 | 26 | <https://github.com/go-openapi/spec/commits?author=pytlesk4> |
| @kul-amr | 10 | <https://github.com/go-openapi/spec/commits?author=kul-amr> |
| @keramix | 10 | <https://github.com/go-openapi/spec/commits?author=keramix> |
@@ -47,4 +47,4 @@
| @ChandanChainani | 1 | <https://github.com/go-openapi/spec/commits?author=ChandanChainani> |
| @bvwells | 1 | <https://github.com/go-openapi/spec/commits?author=bvwells> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+1 -1
View File
@@ -136,7 +136,7 @@ Maintainers can cut a new release by either:
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
+1 -1
View File
@@ -3,4 +3,4 @@
.idea
.env
.mcp.json
.claude/
go.work.sum
+2 -2
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 40 | 225 |
| 40 | 234 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 88 | <https://github.com/go-openapi/strfmt/commits?author=casualjim> |
| @fredbi | 57 | <https://github.com/go-openapi/strfmt/commits?author=fredbi> |
| @fredbi | 66 | <https://github.com/go-openapi/strfmt/commits?author=fredbi> |
| @youyuanwu | 13 | <https://github.com/go-openapi/strfmt/commits?author=youyuanwu> |
| @jlambatl | 9 | <https://github.com/go-openapi/strfmt/commits?author=jlambatl> |
| @GlenDC | 5 | <https://github.com/go-openapi/strfmt/commits?author=GlenDC> |
+7 -14
View File
@@ -16,14 +16,6 @@ Golang support for string formats defined by JSON Schema and OpenAPI.
## Announcements
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
* **2026-03-07** : v0.26.0 **dropped dependency to the mongodb driver**
* mongodb users can still use this package without any change
* however, we have frozen the back-compatible support for mongodb driver at v2.5.0
@@ -177,9 +169,9 @@ This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE).
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -214,9 +206,6 @@ Maintainers can cut a new release by either:
[doc-url]: https://goswagger.io/go-openapi
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/strfmt
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/strfmt
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/FfnFYaC3k5
@@ -228,3 +217,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/strfmt/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/strfmt
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/strfmt/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+257 -71
View File
@@ -7,11 +7,9 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
func init() { //nolint:gochecknoinits // registers duration format in the default registry
@@ -22,34 +20,62 @@ func init() { //nolint:gochecknoinits // registers duration format in the defaul
const (
hoursInDay = 24
daysInWeek = 7
nanos = uint64(time.Nanosecond)
micros = uint64(time.Microsecond)
millis = uint64(time.Millisecond)
seconds = uint64(time.Second)
minutes = uint64(time.Minute)
hours = uint64(time.Hour)
days = uint64(hoursInDay * time.Hour)
weeks = uint64(hoursInDay * daysInWeek * time.Hour)
maxUint64 = uint64(1 << 63)
)
// timeMultiplier holds all supported aliases for duration units, including their plural form.
//
//nolint:gochecknoglobals // package-level lookup tables for duration parsing
var (
timeUnits = [][]string{
{"ns", "nano"},
{"us", "µs", "micro"},
{"ms", "milli"},
{"s", "sec"},
{"m", "min"},
{"h", "hr", "hour"},
{"d", "day"},
{"w", "wk", "week"},
}
timeMultiplier = map[string]time.Duration{
"ns": time.Nanosecond,
"us": time.Microsecond,
"ms": time.Millisecond,
"s": time.Second,
"m": time.Minute,
"h": time.Hour,
"d": hoursInDay * time.Hour,
"w": hoursInDay * daysInWeek * time.Hour,
}
durationMatcher = regexp.MustCompile(`^(((?:-\s?)?\d+)(\.\d+)?\s*([A-Za-zµ]+))`)
)
var timeMultiplier = map[string]uint64{
"ns": nanos,
"nano": nanos,
"nanosecond": nanos,
"nanoseconds": nanos,
"nanos": nanos,
"us": micros,
"µs": micros, // U+00B5 = micro symbol
"μs": micros, // U+03BC = Greek letter mu
"micro": micros,
"micros": micros,
"microsecond": micros,
"microseconds": micros,
"ms": millis,
"milli": millis,
"millis": millis,
"millisecond": millis,
"milliseconds": millis,
"s": seconds,
"sec": seconds,
"secs": seconds,
"second": seconds,
"seconds": seconds,
"m": minutes,
"min": minutes,
"mins": minutes,
"minute": minutes,
"minutes": minutes,
"h": hours,
"hr": hours,
"hrs": hours,
"hour": hours,
"hours": hours,
"d": days,
"day": days,
"days": days,
"w": weeks,
"wk": weeks,
"wks": weeks,
"week": weeks,
"weeks": weeks,
}
// IsDuration returns true if the provided string is a valid duration.
func IsDuration(str string) bool {
@@ -80,64 +106,157 @@ func (d *Duration) UnmarshalText(data []byte) error { // validation is performed
return nil
}
// ParseDuration parses a duration from a string, compatible with scala duration syntax.
func ParseDuration(cand string) (time.Duration, error) {
if dur, err := time.ParseDuration(cand); err == nil {
return dur, nil
// ParseDuration parses a duration from a string
//
// It is similar to [time.ParseDuration] but support additional units like days and weeks,
// additional abreviations for units and is more tolerant on the presence of blank spaces.
//
// A duration may be negative or fractional.
//
// # Differences with [time.ParseDuration]
//
// - more supported units and aliases (see below)
// - sign followed by blank space is tolerated
// - tolerates blanks between duration and unit (e.g. "300 ms")
//
// # Supported units
//
// Units may be specified using aliases or a plural form.
//
// - "ns", "nano", "nanosecond", "nanoseconds", "nanos"
// - "us", "µs" (U+00B5 = micro symbol), "μs" (U+03BC = Greek letter mu), "micro", "micros", "microsecond", "microseconds"
// - "ms", "milli", "millis", "millisecond", "milliseconds"
// - "s", "sec", "secs", "second", "seconds"
// - "m", "min", "mins", "minute", "minutes"
// - "h", "hr", "hrs", "hour", "hours"
// - "d", "day", "days"
// - "w", "wk", "wks", "week", "weeks"
//
// NOTE: inspired by scala duration syntax.
//
// # Examples
//
// "300ms", "-1.5h", "2h45m",
// ".5 week",
// "2 minutes 45 seconds".
//
//nolint:gocognit,gocyclo,cyclop // complexity is only slightly above the usual level, may be tolerated as it mimicks the stdlib.
func ParseDuration(s string) (time.Duration, error) {
// NOTE: this code is largely inspired by the standard library.
orig := s
var d uint64
neg := false
// Consume [-+]?
if s != "" {
c := s[0]
if c == '-' || c == '+' {
neg = c == '-'
s = s[1:]
}
}
var dur time.Duration
ok := false
const expectGroups = 4
for _, match := range durationMatcher.FindAllStringSubmatch(cand, -1) {
if len(match) < expectGroups {
continue
// Consume space
s = strings.TrimLeftFunc(s, unicode.IsSpace)
// Special case: if all that is left is "0", this is zero.
if s == "0" {
return 0, nil
}
if s == "" {
return 0, parseDurationError(orig, "empty duration")
}
for s != "" {
var (
v, f uint64 // integers before, after decimal point
scale float64 = 1 // value = v + f/scale
)
s = strings.TrimLeftFunc(s, unicode.IsSpace)
// The next character must be 0-9.]
if s[0] != '.' && ('0' > s[0] || s[0] > '9') {
return 0, parseDurationError(orig, fmt.Sprintf("expected a numerical value, but got %q", s[0]))
}
// remove possible leading - and spaces
value, negative := strings.CutPrefix(match[2], "-")
// Consume integer part [0-9]*
pl := len(s)
var ok bool
v, s, ok = leadingInt(s)
if !ok {
return 0, parseDurationError(orig, "expected a leading integer part")
}
pre := pl != len(s) // whether we consumed anything before a period
// if the duration contains a decimal separator determine a divising factor
const neutral = 1.0
divisor := neutral
decimal, hasDecimal := strings.CutPrefix(match[3], ".")
if hasDecimal {
divisor = math.Pow10(len(decimal))
value += decimal // consider the value as an integer: will change units later on
// Consume fractional part (\.[0-9]*)?
post := false
if s != "" && s[0] == '.' {
s = s[1:]
pl := len(s)
f, scale, s = leadingFraction(s)
post = pl != len(s)
}
// if the string is a valid duration, parse it
factor, err := strconv.Atoi(strings.TrimSpace(value)) // converts string to int
if err != nil {
return 0, err
if !pre && !post {
// no digits (e.g. ".s" or "-.s")
return 0, parseDurationError(orig, "expected digits")
}
if negative {
factor = -factor
}
// Consume space.
s = strings.TrimLeftFunc(s, unicode.IsSpace)
unit := strings.ToLower(strings.TrimSpace(match[4]))
for _, variants := range timeUnits {
last := len(variants) - 1
multiplier := timeMultiplier[variants[0]]
for i, variant := range variants {
if (last == i && strings.HasPrefix(unit, variant)) || strings.EqualFold(variant, unit) {
ok = true
if divisor != neutral {
multiplier = time.Duration(float64(multiplier) / divisor) // convert to duration only after having reduced the scale
}
dur += (time.Duration(factor) * multiplier)
}
// Consume unit.
i := 0
for ; i < len(s); i++ {
c := s[i]
if c == '.' || '0' <= c && c <= '9' || unicode.IsSpace(rune(c)) {
break
}
}
if i == 0 {
return 0, parseDurationError(orig, "missing unit in duration")
}
u := s[:i]
s = s[i:]
unit, ok := timeMultiplier[u]
if !ok {
return 0, parseDurationError(orig, fmt.Sprintf("unknown unit %q in duration", u))
}
if v > maxUint64/unit {
// overflow
return 0, parseDurationError(orig, "numerical overflow")
}
v *= unit
if f > 0 {
// float64 is needed to be nanosecond accurate for fractions of hours.
// v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit)
v += uint64(float64(f) * (float64(unit) / scale))
if v > maxUint64 {
// overflow
return 0, parseDurationError(orig, "numerical overflow")
}
}
d += v
if d > maxUint64 {
return 0, parseDurationError(orig, "numerical overflow")
}
}
if ok {
return dur, nil
if neg {
return -time.Duration(d), nil
}
return 0, fmt.Errorf("unable to parse %s as duration: %w", cand, ErrFormat)
if d > maxUint64-1 {
return 0, parseDurationError(orig, "numerical overflow")
}
return time.Duration(d), nil
}
// Scan reads a Duration value from database driver type.
@@ -204,3 +323,70 @@ func (d *Duration) DeepCopy() *Duration {
d.DeepCopyInto(out)
return out
}
func parseDurationError(s, msg string) error {
if msg == "" {
return fmt.Errorf("invalid duration: %s: %w", s, ErrFormat)
}
return fmt.Errorf("invalid duration: %s: %s: %w", s, msg, ErrFormat)
}
// leadingInt consumes the leading [0-9]* from s.
func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, ok bool) { //nolint:ireturn // false positive
i := 0
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
if x > maxUint64/10 { // overflow
return 0, rem, false
}
x = x*10 + uint64(c) - '0'
if x > maxUint64 { // overflow
return 0, rem, false
}
}
return x, s[i:], true
}
// leadingFraction consumes the leading [0-9]* from s.
// //
// It is used only for fractions, so it does not return an error on overflow,
// it just stops accumulating precision.
func leadingFraction(s string) (x uint64, scale float64, rem string) {
i := 0
scale = 1
overflow := false
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
if overflow {
continue
}
if x > (maxUint64-1)/10 {
// It's possible for overflow to give a positive number, so take care.
overflow = true
continue
}
y := x*10 + uint64(c) - '0'
if y > maxUint64 {
overflow = true
continue
}
x = y
scale *= 10
}
return x, scale, s[i:]
}
+1 -1
View File
@@ -4,4 +4,4 @@ use (
./internal/testintegration
)
go 1.24.0
go 1.25.0
-16
View File
@@ -1,16 +0,0 @@
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-1
View File
@@ -4,4 +4,3 @@ Godeps
.idea
*.out
.mcp.json
.claude/
+3 -3
View File
@@ -4,11 +4,11 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 24 | 235 |
| 24 | 242 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 105 | <https://github.com/go-openapi/swag/commits?author=fredbi> |
| @fredbi | 112 | <https://github.com/go-openapi/swag/commits?author=fredbi> |
| @casualjim | 98 | <https://github.com/go-openapi/swag/commits?author=casualjim> |
| @alexandear | 4 | <https://github.com/go-openapi/swag/commits?author=alexandear> |
| @orisano | 3 | <https://github.com/go-openapi/swag/commits?author=orisano> |
@@ -33,4 +33,4 @@
| @davidalpert | 1 | <https://github.com/go-openapi/swag/commits?author=davidalpert> |
| @Xe | 1 | <https://github.com/go-openapi/swag/commits?author=Xe> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+1 -1
View File
@@ -212,7 +212,7 @@ Maintainers can cut a new release by either:
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
+23 -5
View File
@@ -6,14 +6,32 @@ This policy outlines the commitment and practices of the go-openapi maintainers
| Version | Supported |
| ------- | ------------------ |
| 0.25.x | :white_check_mark: |
| O.x | :white_check_mark: |
## Vulnerability checks in place
This repository uses automated vulnerability scans, at every merged commit and at least once a week.
We use:
* [`GitHub CodeQL`][codeql-url]
* [`trivy`][trivy-url]
* [`govulncheck`][govulncheck-url]
Reports are centralized in github security reports and visible only to the maintainers.
## Reporting a vulnerability
If you become aware of a security vulnerability that affects the current repository,
please report it privately to the maintainers.
**please report it privately to the maintainers**
rather than opening a publicly visible GitHub issue.
Please follow the instructions provided by github to
[Privately report a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability).
Please follow the instructions provided by github to [Privately report a security vulnerability][github-guidance-url].
TL;DR: on Github, navigate to the project's "Security" tab then click on "Report a vulnerability".
> [!NOTE]
> On Github, navigate to the project's "Security" tab then click on "Report a vulnerability".
[codeql-url]: https://github.com/github/codeql
[trivy-url]: https://trivy.dev/docs/latest/getting-started
[govulncheck-url]: https://go.dev/blog/govulncheck
[github-guidance-url]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability
+1 -1
View File
@@ -17,4 +17,4 @@ use (
./yamlutils
)
go 1.24.0
go 1.25.0

Some files were not shown because too many files have changed in this diff Show More