vendor: update buildkit to v0.31.0-rc2
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
+4
@@ -1,3 +1,7 @@
|
||||
codecov:
|
||||
notify:
|
||||
after_n_builds: 2
|
||||
|
||||
coverage:
|
||||
status:
|
||||
patch:
|
||||
|
||||
+2
-1
@@ -3,4 +3,5 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
.claude/
|
||||
go.work.sum
|
||||
.worktrees
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ linters:
|
||||
- recvcheck
|
||||
- testpackage
|
||||
- thelper
|
||||
- tagliatelle
|
||||
- tparallel
|
||||
- varnamelen
|
||||
- whitespace
|
||||
|
||||
+19
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
use (
|
||||
.
|
||||
|
||||
-29
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -3,4 +3,3 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
+4
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
+11
-11
@@ -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
@@ -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
@@ -0,0 +1,9 @@
|
||||
codecov:
|
||||
notify:
|
||||
after_n_builds: 2
|
||||
|
||||
coverage:
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
target: 80%
|
||||
+2
@@ -3,3 +3,5 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
go.work.sum
|
||||
.worktrees/
|
||||
|
||||
+20
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 60–350s
|
||||
// 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,8 @@
|
||||
use (
|
||||
.
|
||||
./client-middleware/opentracing
|
||||
./docs/examples
|
||||
./server-middleware
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
-109
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
+24
@@ -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)
|
||||
}
|
||||
+61
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+67
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
+138
@@ -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>
|
||||
`
|
||||
)
|
||||
Generated
Vendored
+36
-11
@@ -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>
|
||||
`
|
||||
)
|
||||
+30
@@ -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
|
||||
+116
@@ -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
|
||||
}
|
||||
+65
@@ -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
|
||||
}
|
||||
+379
@@ -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
@@ -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
|
||||
}
|
||||
+13
@@ -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
|
||||
Generated
Vendored
+205
@@ -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
@@ -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
@@ -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
@@ -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
@@ -3,4 +3,3 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -3,4 +3,4 @@
|
||||
.idea
|
||||
.env
|
||||
.mcp.json
|
||||
.claude/
|
||||
go.work.sum
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -4,4 +4,4 @@ use (
|
||||
./internal/testintegration
|
||||
)
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
-16
@@ -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
@@ -4,4 +4,3 @@ Godeps
|
||||
.idea
|
||||
*.out
|
||||
.mcp.json
|
||||
.claude/
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user