New Upstream Release - golang-github-go-openapi-runtime

Ready changes

Summary

Merged new upstream version: 0.25.0 (was: 0.23.3).

Diff

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index cac5e4e..6d8db66 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -17,7 +17,7 @@ jobs:
     - name: Set up Go
       uses: actions/setup-go@v2
       with:
-        go-version: 1.17.1
+        go-version: 1.18.8
 
     - name: Setup gotestsum
       uses: autero1/action-gotestsum@v1.0.0
diff --git a/client/keepalive.go b/client/keepalive.go
index e9c250d..bc7b7fa 100644
--- a/client/keepalive.go
+++ b/client/keepalive.go
@@ -2,7 +2,6 @@ package client
 
 import (
 	"io"
-	"io/ioutil"
 	"net/http"
 	"sync/atomic"
 )
@@ -50,7 +49,7 @@ func (d *drainingReadCloser) Close() error {
 		// some bytes, but the closer ignores them to keep the underling
 		// connection open.
 		//nolint:errcheck
-		io.Copy(ioutil.Discard, d.rdr)
+		io.Copy(io.Discard, d.rdr)
 	}
 	return d.rdr.Close()
 }
diff --git a/client/keepalive_test.go b/client/keepalive_test.go
index d6b95b4..5046450 100644
--- a/client/keepalive_test.go
+++ b/client/keepalive_test.go
@@ -3,7 +3,6 @@ package client
 import (
 	"bytes"
 	"io"
-	"io/ioutil"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -39,10 +38,10 @@ func (c *countingReadCloser) Close() error {
 
 func TestDrainingReadCloser(t *testing.T) {
 	rdr := newCountingReader(bytes.NewBufferString("There are many things to do"), false)
-	prevDisc := ioutil.Discard
+	prevDisc := io.Discard
 	disc := bytes.NewBuffer(nil)
-	ioutil.Discard = disc
-	defer func() { ioutil.Discard = prevDisc }()
+	io.Discard = disc
+	defer func() { io.Discard = prevDisc }()
 
 	buf := make([]byte, 5)
 	ts := &drainingReadCloser{rdr: rdr}
@@ -57,10 +56,10 @@ func TestDrainingReadCloser(t *testing.T) {
 
 func TestDrainingReadCloser_SeenEOF(t *testing.T) {
 	rdr := newCountingReader(bytes.NewBufferString("There are many things to do"), true)
-	prevDisc := ioutil.Discard
+	prevDisc := io.Discard
 	disc := bytes.NewBuffer(nil)
-	ioutil.Discard = disc
-	defer func() { ioutil.Discard = prevDisc }()
+	io.Discard = disc
+	defer func() { io.Discard = prevDisc }()
 
 	buf := make([]byte, 5)
 	ts := &drainingReadCloser{rdr: rdr}
diff --git a/client/opentelemetry.go b/client/opentelemetry.go
new file mode 100644
index 0000000..8a38ea3
--- /dev/null
+++ b/client/opentelemetry.go
@@ -0,0 +1,207 @@
+package client
+
+import (
+	"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.12.0"
+	"go.opentelemetry.io/otel/trace"
+)
+
+const (
+	instrumentationVersion = "1.0.0"
+	tracerName             = "go-openapi"
+)
+
+type config struct {
+	Tracer            trace.Tracer
+	Propagator        propagation.TextMapPropagator
+	SpanStartOptions  []trace.SpanStartOption
+	SpanNameFormatter func(*runtime.ClientOperation) string
+	TracerProvider    trace.TracerProvider
+}
+
+type OpenTelemetryOpt interface {
+	apply(*config)
+}
+
+type optionFunc func(*config)
+
+func (o optionFunc) apply(c *config) {
+	o(c)
+}
+
+// WithTracerProvider specifies a tracer provider to use for creating a tracer.
+// If none is specified, the global provider is used.
+func WithTracerProvider(provider trace.TracerProvider) OpenTelemetryOpt {
+	return optionFunc(func(c *config) {
+		if provider != nil {
+			c.TracerProvider = provider
+		}
+	})
+}
+
+// WithPropagators configures specific propagators. If this
+// option isn't specified, then the global TextMapPropagator is used.
+func WithPropagators(ps propagation.TextMapPropagator) OpenTelemetryOpt {
+	return optionFunc(func(c *config) {
+		if ps != nil {
+			c.Propagator = ps
+		}
+	})
+}
+
+// WithSpanOptions configures an additional set of
+// trace.SpanOptions, which are applied to each new span.
+func WithSpanOptions(opts ...trace.SpanStartOption) OpenTelemetryOpt {
+	return optionFunc(func(c *config) {
+		c.SpanStartOptions = append(c.SpanStartOptions, opts...)
+	})
+}
+
+// WithSpanNameFormatter takes a function that will be called on every
+// request and the returned string will become the Span Name.
+func WithSpanNameFormatter(f func(op *runtime.ClientOperation) string) OpenTelemetryOpt {
+	return optionFunc(func(c *config) {
+		c.SpanNameFormatter = f
+	})
+}
+
+func defaultTransportFormatter(op *runtime.ClientOperation) string {
+	if op.ID != "" {
+		return op.ID
+	}
+
+	return fmt.Sprintf("%s_%s", strings.ToLower(op.Method), op.PathPattern)
+}
+
+type openTelemetryTransport struct {
+	transport runtime.ClientTransport
+	host      string
+	tracer    trace.Tracer
+	config    *config
+}
+
+func newOpenTelemetryTransport(transport runtime.ClientTransport, host string, opts []OpenTelemetryOpt) *openTelemetryTransport {
+	tr := &openTelemetryTransport{
+		transport: transport,
+		host:      host,
+	}
+
+	defaultOpts := []OpenTelemetryOpt{
+		WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
+		WithSpanNameFormatter(defaultTransportFormatter),
+		WithPropagators(otel.GetTextMapPropagator()),
+		WithTracerProvider(otel.GetTracerProvider()),
+	}
+
+	c := newConfig(append(defaultOpts, opts...)...)
+	tr.config = c
+
+	return tr
+}
+
+func (t *openTelemetryTransport) Submit(op *runtime.ClientOperation) (interface{}, error) {
+	if op.Context == nil {
+		return t.transport.Submit(op)
+	}
+
+	params := op.Params
+	reader := op.Reader
+
+	var span trace.Span
+	defer func() {
+		if span != nil {
+			span.End()
+		}
+	}()
+
+	op.Params = runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
+		span = t.newOpenTelemetrySpan(op, req.GetHeaderParams())
+		return params.WriteToRequest(req, reg)
+	})
+
+	op.Reader = runtime.ClientResponseReaderFunc(func(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
+		if span != nil {
+			statusCode := response.Code()
+			span.SetAttributes(attribute.Int(string(semconv.HTTPStatusCodeKey), statusCode))
+			span.SetStatus(semconv.SpanStatusFromHTTPStatusCodeAndSpanKind(statusCode, trace.SpanKindClient))
+		}
+
+		return reader.ReadResponse(response, consumer)
+	})
+
+	submit, err := t.transport.Submit(op)
+	if err != nil && span != nil {
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+	}
+
+	return submit, err
+}
+
+func (t *openTelemetryTransport) newOpenTelemetrySpan(op *runtime.ClientOperation, header http.Header) trace.Span {
+	ctx := op.Context
+
+	tracer := t.tracer
+	if tracer == nil {
+		if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
+			tracer = newTracer(span.TracerProvider())
+		} else {
+			tracer = newTracer(otel.GetTracerProvider())
+		}
+	}
+
+	ctx, span := tracer.Start(ctx, t.config.SpanNameFormatter(op), t.config.SpanStartOptions...)
+
+	var scheme string
+	if len(op.Schemes) > 0 {
+		scheme = op.Schemes[0]
+	}
+
+	span.SetAttributes(
+		attribute.String("net.peer.name", t.host),
+		attribute.String(string(semconv.HTTPRouteKey), op.PathPattern),
+		attribute.String(string(semconv.HTTPMethodKey), op.Method),
+		attribute.String("span.kind", trace.SpanKindClient.String()),
+		attribute.String("http.scheme", scheme),
+	)
+
+	carrier := propagation.HeaderCarrier(header)
+	t.config.Propagator.Inject(ctx, carrier)
+
+	return span
+}
+
+func newTracer(tp trace.TracerProvider) trace.Tracer {
+	return tp.Tracer(tracerName, trace.WithInstrumentationVersion(version()))
+}
+
+func newConfig(opts ...OpenTelemetryOpt) *config {
+	c := &config{
+		Propagator: otel.GetTextMapPropagator(),
+	}
+
+	for _, opt := range opts {
+		opt.apply(c)
+	}
+
+	// Tracer is only initialized if manually specified. Otherwise, can be passed with the tracing context.
+	if c.TracerProvider != nil {
+		c.Tracer = newTracer(c.TracerProvider)
+	}
+
+	return c
+}
+
+// Version is the current release version of the go-runtime instrumentation.
+func version() string {
+	return instrumentationVersion
+}
diff --git a/client/opentelemetry_test.go b/client/opentelemetry_test.go
new file mode 100644
index 0000000..94d8ead
--- /dev/null
+++ b/client/opentelemetry_test.go
@@ -0,0 +1,124 @@
+package client
+
+import (
+	"context"
+	"testing"
+
+	"github.com/go-openapi/runtime"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	"go.opentelemetry.io/otel/propagation"
+	tracesdk "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
+	"go.opentelemetry.io/otel/trace"
+)
+
+func Test_OpenTelemetryRuntime_submit(t *testing.T) {
+	t.Parallel()
+
+	exporter := tracetest.NewInMemoryExporter()
+
+	tp := tracesdk.NewTracerProvider(
+		tracesdk.WithSampler(tracesdk.AlwaysSample()),
+		tracesdk.WithSyncer(exporter),
+	)
+
+	otel.SetTracerProvider(tp)
+
+	tracer := tp.Tracer("go-runtime")
+	ctx, _ := tracer.Start(context.Background(), "op")
+
+	assertOpenTelemetrySubmit(t, testOperation(ctx), exporter, 1)
+}
+
+func Test_OpenTelemetryRuntime_submit_nilAuthInfo(t *testing.T) {
+	t.Parallel()
+
+	exporter := tracetest.NewInMemoryExporter()
+
+	tp := tracesdk.NewTracerProvider(
+		tracesdk.WithSampler(tracesdk.AlwaysSample()),
+		tracesdk.WithSyncer(exporter),
+	)
+
+	otel.SetTracerProvider(tp)
+
+	tracer := tp.Tracer("go-runtime")
+	ctx, _ := tracer.Start(context.Background(), "op")
+
+	operation := testOperation(ctx)
+	operation.AuthInfo = nil
+	assertOpenTelemetrySubmit(t, operation, exporter, 1)
+}
+
+func Test_OpenTelemetryRuntime_submit_nilContext(t *testing.T) {
+	exporter := tracetest.NewInMemoryExporter()
+
+	tp := tracesdk.NewTracerProvider(
+		tracesdk.WithSampler(tracesdk.AlwaysSample()),
+		tracesdk.WithSyncer(exporter),
+	)
+
+	otel.SetTracerProvider(tp)
+
+	tracer := tp.Tracer("go-runtime")
+	ctx, _ := tracer.Start(context.Background(), "op")
+	operation := testOperation(ctx)
+	operation.Context = nil
+
+	assertOpenTelemetrySubmit(t, operation, exporter, 0) // just don't panic
+}
+
+func Test_injectOpenTelemetrySpanContext(t *testing.T) {
+	t.Parallel()
+
+	exporter := tracetest.NewInMemoryExporter()
+
+	tp := tracesdk.NewTracerProvider(
+		tracesdk.WithSampler(tracesdk.AlwaysSample()),
+		tracesdk.WithSyncer(exporter),
+	)
+
+	otel.SetTracerProvider(tp)
+
+	tracer := tp.Tracer("go-runtime")
+	ctx, _ := tracer.Start(context.Background(), "op")
+	operation := testOperation(ctx)
+
+	header := map[string][]string{}
+	tr := newOpenTelemetryTransport(&mockRuntime{runtime.TestClientRequest{Headers: header}}, "", nil)
+	tr.config.Propagator = propagation.TraceContext{}
+	_, err := tr.Submit(operation)
+	assert.NoError(t, err)
+
+	assert.Len(t, header, 1)
+}
+
+func assertOpenTelemetrySubmit(t *testing.T, operation *runtime.ClientOperation, exporter *tracetest.InMemoryExporter, expectedSpanCount int) {
+	header := map[string][]string{}
+	tr := newOpenTelemetryTransport(&mockRuntime{runtime.TestClientRequest{Headers: header}}, "remote_host", nil)
+
+	_, err := tr.Submit(operation)
+	require.NoError(t, err)
+
+	spans := exporter.GetSpans()
+	assert.Len(t, spans, expectedSpanCount)
+
+	if expectedSpanCount == 1 {
+		span := spans[0]
+		assert.Equal(t, "getCluster", span.Name)
+		assert.Equal(t, "go-openapi", span.InstrumentationLibrary.Name)
+		assert.Equal(t, span.Status.Code, codes.Error)
+		assert.Equal(t, []attribute.KeyValue{
+			attribute.String("net.peer.name", "remote_host"),
+			attribute.String("http.route", "/kubernetes-clusters/{cluster_id}"),
+			attribute.String("http.method", "GET"),
+			attribute.String("span.kind", trace.SpanKindClient.String()),
+			attribute.String("http.scheme", "https"),
+			attribute.Int("http.status_code", 490),
+		}, span.Attributes)
+	}
+}
diff --git a/client/opentracing_test.go b/client/opentracing_test.go
index 650a437..d3df065 100644
--- a/client/opentracing_test.go
+++ b/client/opentracing_test.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	"io"
-	"io/ioutil"
 	"testing"
 
 	"github.com/go-openapi/strfmt"
@@ -33,7 +32,7 @@ func (r tres) GetHeaders(_ string) []string {
 	return []string{"the headers", "the headers2"}
 }
 func (r tres) Body() io.ReadCloser {
-	return ioutil.NopCloser(bytes.NewBufferString("the content"))
+	return io.NopCloser(bytes.NewBufferString("the content"))
 }
 
 type mockRuntime struct {
diff --git a/client/request_test.go b/client/request_test.go
index a6d73af..e6a0698 100644
--- a/client/request_test.go
+++ b/client/request_test.go
@@ -20,7 +20,6 @@ import (
 	"encoding/xml"
 	"errors"
 	"io"
-	"io/ioutil"
 	"mime"
 	"mime/multipart"
 	"net/http"
@@ -165,7 +164,7 @@ func TestBuildRequest_BuildHTTP_Payload(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expectedBody, _ := json.Marshal(bd)
-		actualBody, _ := ioutil.ReadAll(req.Body)
+		actualBody, _ := io.ReadAll(req.Body)
 		assert.Equal(t, append(expectedBody, '\n'), actualBody)
 	}
 }
@@ -197,7 +196,7 @@ func TestBuildRequest_BuildHTTP_SetsInAuth(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expectedBody, _ := json.Marshal(bd)
-		actualBody, _ := ioutil.ReadAll(req.Body)
+		actualBody, _ := io.ReadAll(req.Body)
 		assert.Equal(t, append(expectedBody, '\n'), actualBody)
 	}
 }
@@ -224,7 +223,7 @@ func TestBuildRequest_BuildHTTP_XMLPayload(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expectedBody, _ := xml.Marshal(bd)
-		actualBody, _ := ioutil.ReadAll(req.Body)
+		actualBody, _ := io.ReadAll(req.Body)
 		assert.Equal(t, expectedBody, actualBody)
 	}
 }
@@ -247,7 +246,7 @@ func TestBuildRequest_BuildHTTP_TextPayload(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expectedBody := []byte(bd)
-		actualBody, _ := ioutil.ReadAll(req.Body)
+		actualBody, _ := io.ReadAll(req.Body)
 		assert.Equal(t, expectedBody, actualBody)
 	}
 }
@@ -269,7 +268,7 @@ func TestBuildRequest_BuildHTTP_Form(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expected := []byte("something=some+value")
-		actual, _ := ioutil.ReadAll(req.Body)
+		actual, _ := io.ReadAll(req.Body)
 		assert.Equal(t, expected, actual)
 	}
 }
@@ -292,7 +291,7 @@ func TestBuildRequest_BuildHTTP_Form_URLEncoded(t *testing.T) {
 		assert.Equal(t, "world", req.URL.Query().Get("hello"))
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expected := []byte("something=some+value")
-		actual, _ := ioutil.ReadAll(req.Body)
+		actual, _ := io.ReadAll(req.Body)
 		assert.Equal(t, expected, actual)
 	}
 }
@@ -316,7 +315,7 @@ func TestBuildRequest_BuildHTTP_Form_Content_Length(t *testing.T) {
 		assert.Condition(t, func() bool { return req.ContentLength > 0 },
 			"ContentLength must great than 0. got %d", req.ContentLength)
 		expected := []byte("something=some+value")
-		actual, _ := ioutil.ReadAll(req.Body)
+		actual, _ := io.ReadAll(req.Body)
 		assert.Equal(t, expected, actual)
 	}
 }
@@ -339,7 +338,7 @@ func TestBuildRequest_BuildHTTP_FormMultipart(t *testing.T) {
 		assert.Equal(t, "/flats/1234/", req.URL.Path)
 		expected1 := []byte("Content-Disposition: form-data; name=\"something\"")
 		expected2 := []byte("some value")
-		actual, _ := ioutil.ReadAll(req.Body)
+		actual, _ := io.ReadAll(req.Body)
 		actuallines := bytes.Split(actual, []byte("\r\n"))
 		assert.Equal(t, 6, len(actuallines))
 		boundary := string(actuallines[0])
@@ -371,7 +370,7 @@ func TestBuildRequest_BuildHTTP_FormMultiples(t *testing.T) {
 		expected1 := []byte("Content-Disposition: form-data; name=\"something\"")
 		expected2 := []byte("some value")
 		expected3 := []byte("another value")
-		actual, _ := ioutil.ReadAll(req.Body)
+		actual, _ := io.ReadAll(req.Body)
 		actuallines := bytes.Split(actual, []byte("\r\n"))
 		assert.Equal(t, 10, len(actuallines))
 		boundary := string(actuallines[0])
@@ -388,8 +387,8 @@ func TestBuildRequest_BuildHTTP_FormMultiples(t *testing.T) {
 }
 
 func TestBuildRequest_BuildHTTP_Files(t *testing.T) {
-	cont, _ := ioutil.ReadFile("./runtime.go")
-	cont2, _ := ioutil.ReadFile("./request.go")
+	cont, _ := os.ReadFile("./runtime.go")
+	cont2, _ := os.ReadFile("./request.go")
 	reqWrtr := runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
 		_ = req.SetFormParam("something", "some value")
 		_ = req.SetFileParam("file", mustGetFile("./runtime.go"))
@@ -420,7 +419,7 @@ func TestBuildRequest_BuildHTTP_Files(t *testing.T) {
 					mpf, _ := mpff.Open()
 					defer mpf.Close()
 					assert.Equal(t, filename, mpff.Filename)
-					actual, _ := ioutil.ReadAll(mpf)
+					actual, _ := io.ReadAll(mpf)
 					assert.Equal(t, content, actual)
 				}
 				fileverifier("file", 0, "runtime.go", cont)
@@ -432,8 +431,8 @@ func TestBuildRequest_BuildHTTP_Files(t *testing.T) {
 	}
 }
 func TestBuildRequest_BuildHTTP_Files_URLEncoded(t *testing.T) {
-	cont, _ := ioutil.ReadFile("./runtime.go")
-	cont2, _ := ioutil.ReadFile("./request.go")
+	cont, _ := os.ReadFile("./runtime.go")
+	cont2, _ := os.ReadFile("./request.go")
 	reqWrtr := runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, reg strfmt.Registry) error {
 		_ = req.SetFormParam("something", "some value")
 		_ = req.SetFileParam("file", mustGetFile("./runtime.go"))
@@ -464,7 +463,7 @@ func TestBuildRequest_BuildHTTP_Files_URLEncoded(t *testing.T) {
 					mpf, _ := mpff.Open()
 					defer mpf.Close()
 					assert.Equal(t, filename, mpff.Filename)
-					actual, _ := ioutil.ReadAll(mpf)
+					actual, _ := io.ReadAll(mpf)
 					assert.Equal(t, content, actual)
 				}
 				fileverifier("file", 0, "runtime.go", cont)
@@ -611,7 +610,7 @@ func TestGetBodyCallsBeforeRoundTrip(t *testing.T) {
 			// Read the body once before sending the request
 			body, err := req.GetBody()
 			require.NoError(t, err)
-			bodyContent, err := ioutil.ReadAll(io.Reader(body))
+			bodyContent, err := io.ReadAll(io.Reader(body))
 			require.EqualValues(t, req.ContentLength, len(bodyContent))
 			require.NoError(t, err)
 			require.EqualValues(t, "\"test body\"\n", string(bodyContent))
@@ -619,7 +618,7 @@ func TestGetBodyCallsBeforeRoundTrip(t *testing.T) {
 			// Read the body a second time before sending the request
 			body, err = req.GetBody()
 			require.NoError(t, err)
-			bodyContent, err = ioutil.ReadAll(io.Reader(body))
+			bodyContent, err = io.ReadAll(io.Reader(body))
 			require.NoError(t, err)
 			require.EqualValues(t, req.ContentLength, len(bodyContent))
 			require.EqualValues(t, "\"test body\"\n", string(bodyContent))
diff --git a/client/response.go b/client/response.go
index b297a12..0bbd388 100644
--- a/client/response.go
+++ b/client/response.go
@@ -23,6 +23,8 @@ import (
 
 var _ runtime.ClientResponse = response{}
 
+func newResponse(resp *http.Response) runtime.ClientResponse { return response{resp: resp} }
+
 type response struct {
 	resp *http.Response
 }
diff --git a/client/response_test.go b/client/response_test.go
index 5d4694e..458dd04 100644
--- a/client/response_test.go
+++ b/client/response_test.go
@@ -16,7 +16,7 @@ package client
 
 import (
 	"bytes"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"testing"
 
@@ -31,7 +31,7 @@ func TestResponse(t *testing.T) {
 	under.StatusCode = 392
 	under.Header = make(http.Header)
 	under.Header.Set("Blah", "blah blah")
-	under.Body = ioutil.NopCloser(bytes.NewBufferString("some content"))
+	under.Body = io.NopCloser(bytes.NewBufferString("some content"))
 
 	var resp runtime.ClientResponse = response{under}
 	assert.EqualValues(t, under.StatusCode, resp.Code())
diff --git a/client/runtime.go b/client/runtime.go
index 6556bac..ccec041 100644
--- a/client/runtime.go
+++ b/client/runtime.go
@@ -23,21 +23,21 @@ import (
 	"crypto/x509"
 	"encoding/pem"
 	"fmt"
-	"io/ioutil"
 	"mime"
 	"net/http"
 	"net/http/httputil"
+	"os"
 	"strings"
 	"sync"
 	"time"
 
-	"github.com/go-openapi/strfmt"
 	"github.com/opentracing/opentracing-go"
 
 	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/runtime/logger"
 	"github.com/go-openapi/runtime/middleware"
 	"github.com/go-openapi/runtime/yamlpc"
+	"github.com/go-openapi/strfmt"
 )
 
 // TLSClientOptions to configure client authentication with mutual TLS
@@ -164,7 +164,7 @@ func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) {
 		cfg.RootCAs = caCertPool
 	} else if opts.CA != "" {
 		// load ca cert
-		caCert, err := ioutil.ReadFile(opts.CA)
+		caCert, err := os.ReadFile(opts.CA)
 		if err != nil {
 			return nil, fmt.Errorf("tls client ca: %v", err)
 		}
@@ -181,8 +181,6 @@ func TLSClientAuth(opts TLSClientOptions) (*tls.Config, error) {
 		cfg.ServerName = opts.ServerName
 	}
 
-	cfg.BuildNameToCertificate()
-
 	return cfg, nil
 }
 
@@ -225,7 +223,7 @@ type Runtime struct {
 
 	Transport http.RoundTripper
 	Jar       http.CookieJar
-	//Spec      *spec.Document
+	// Spec      *spec.Document
 	Host     string
 	BasePath string
 	Formats  strfmt.Registry
@@ -237,6 +235,7 @@ type Runtime struct {
 	clientOnce *sync.Once
 	client     *http.Client
 	schemes    []string
+	response   ClientResponseFunc
 }
 
 // New creates a new default runtime for a swagger api runtime.Client
@@ -275,6 +274,7 @@ func New(host, basePath string, schemes []string) *Runtime {
 
 	rt.Debug = logger.DebugEnabled()
 	rt.logger = logger.StandardLogger{}
+	rt.response = newResponse
 
 	if len(schemes) > 0 {
 		rt.schemes = schemes
@@ -301,6 +301,14 @@ func (r *Runtime) WithOpenTracing(opts ...opentracing.StartSpanOption) runtime.C
 	return newOpenTracingTransport(r, r.Host, opts)
 }
 
+// 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)
+}
+
 func (r *Runtime) pickScheme(schemes []string) string {
 	if v := r.selectScheme(r.schemes); v != "" {
 		return v
@@ -329,6 +337,7 @@ func (r *Runtime) selectScheme(schemes []string) string {
 	}
 	return scheme
 }
+
 func transportOrDefault(left, right http.RoundTripper) http.RoundTripper {
 	if left == nil {
 		return right
@@ -358,20 +367,19 @@ func (r *Runtime) EnableConnectionReuse() {
 	)
 }
 
-// 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
-func (r *Runtime) Submit(operation *runtime.ClientOperation) (interface{}, error) {
-	params, readResponse, auth := operation.Params, operation.Reader, operation.AuthInfo
+// takes a client operation and creates equivalent http.Request
+func (r *Runtime) createHttpRequest(operation *runtime.ClientOperation) (*request, *http.Request, error) {
+	params, _, auth := operation.Params, operation.Reader, operation.AuthInfo
 
 	request, err := newRequest(operation.Method, operation.PathPattern, params)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	var accept []string
 	accept = append(accept, operation.ProducesMediaTypes...)
 	if err = request.SetHeaderParam(runtime.HeaderAccept, accept...); err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	if auth == nil && r.DefaultAuthentication != nil {
@@ -382,7 +390,7 @@ func (r *Runtime) Submit(operation *runtime.ClientOperation) (interface{}, error
 			return r.DefaultAuthentication.AuthenticateRequest(req, reg)
 		})
 	}
-	//if auth != nil {
+	// if auth != nil {
 	//	if err := auth.AuthenticateRequest(request, r.Formats); err != nil {
 	//		return nil, err
 	//	}
@@ -399,16 +407,33 @@ func (r *Runtime) Submit(operation *runtime.ClientOperation) (interface{}, error
 	}
 
 	if _, ok := r.Producers[cmt]; !ok && cmt != runtime.MultipartFormMime && cmt != runtime.URLencodedFormMime {
-		return nil, fmt.Errorf("none of producers: %v registered. try %s", r.Producers, cmt)
+		return nil, nil, fmt.Errorf("none of producers: %v registered. try %s", r.Producers, cmt)
 	}
 
 	req, err := request.buildHTTP(cmt, r.BasePath, r.Producers, r.Formats, auth)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	req.URL.Scheme = r.pickScheme(operation.Schemes)
 	req.URL.Host = r.Host
 	req.Host = r.Host
+	return request, req, nil
+}
+
+func (r *Runtime) CreateHttpRequest(operation *runtime.ClientOperation) (req *http.Request, err error) {
+	_, req, err = r.createHttpRequest(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
+func (r *Runtime) Submit(operation *runtime.ClientOperation) (interface{}, error) {
+	_, readResponse, _ := operation.Params, operation.Reader, operation.AuthInfo
+
+	request, req, err := r.createHttpRequest(operation)
+	if err != nil {
+		return nil, err
+	}
 
 	r.clientOnce.Do(func() {
 		r.client = &http.Client{
@@ -484,7 +509,7 @@ func (r *Runtime) Submit(operation *runtime.ClientOperation) (interface{}, error
 			return nil, fmt.Errorf("no consumer: %q", ct)
 		}
 	}
-	return readResponse.ReadResponse(response{res}, cons)
+	return readResponse.ReadResponse(r.response(res), cons)
 }
 
 // SetDebug changes the debug flag.
@@ -500,3 +525,13 @@ func (r *Runtime) SetLogger(logger logger.Logger) {
 	r.logger = logger
 	middleware.Logger = logger
 }
+
+type ClientResponseFunc = func(*http.Response) runtime.ClientResponse
+
+// SetResponseReader changes the response reader implementation.
+func (r *Runtime) SetResponseReader(f ClientResponseFunc) {
+	if f == nil {
+		return
+	}
+	r.response = f
+}
diff --git a/client/runtime_test.go b/client/runtime_test.go
index 0b05186..3c4c7c5 100644
--- a/client/runtime_test.go
+++ b/client/runtime_test.go
@@ -19,7 +19,7 @@ import (
 	"encoding/json"
 	"encoding/xml"
 	"errors"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/cookiejar"
 	"net/http/httptest"
@@ -70,7 +70,7 @@ func TestRuntime_TLSAuthConfig(t *testing.T) {
 }
 
 func TestRuntime_TLSAuthConfigWithRSAKey(t *testing.T) {
-	keyPem, err := ioutil.ReadFile("../fixtures/certs/myclient.key")
+	keyPem, err := os.ReadFile("../fixtures/certs/myclient.key")
 	require.NoError(t, err)
 
 	keyDer, _ := pem.Decode(keyPem)
@@ -79,7 +79,7 @@ func TestRuntime_TLSAuthConfigWithRSAKey(t *testing.T) {
 	key, err := x509.ParsePKCS1PrivateKey(keyDer.Bytes)
 	require.NoError(t, err)
 
-	certPem, err := ioutil.ReadFile("../fixtures/certs/myclient.crt")
+	certPem, err := os.ReadFile("../fixtures/certs/myclient.crt")
 	require.NoError(t, err)
 
 	certDer, _ := pem.Decode(certPem)
@@ -101,7 +101,7 @@ func TestRuntime_TLSAuthConfigWithRSAKey(t *testing.T) {
 }
 
 func TestRuntime_TLSAuthConfigWithECKey(t *testing.T) {
-	keyPem, err := ioutil.ReadFile("../fixtures/certs/myclient-ecc.key")
+	keyPem, err := os.ReadFile("../fixtures/certs/myclient-ecc.key")
 	require.NoError(t, err)
 
 	_, remainder := pem.Decode(keyPem)
@@ -111,7 +111,7 @@ func TestRuntime_TLSAuthConfigWithECKey(t *testing.T) {
 	key, err := x509.ParseECPrivateKey(keyDer.Bytes)
 	require.NoError(t, err)
 
-	certPem, err := ioutil.ReadFile("../fixtures/certs/myclient-ecc.crt")
+	certPem, err := os.ReadFile("../fixtures/certs/myclient-ecc.crt")
 	require.NoError(t, err)
 
 	certDer, _ := pem.Decode(certPem)
@@ -133,7 +133,7 @@ func TestRuntime_TLSAuthConfigWithECKey(t *testing.T) {
 }
 
 func TestRuntime_TLSAuthConfigWithLoadedCA(t *testing.T) {
-	certPem, err := ioutil.ReadFile("../fixtures/certs/myCA.crt")
+	certPem, err := os.ReadFile("../fixtures/certs/myCA.crt")
 	require.NoError(t, err)
 
 	block, _ := pem.Decode(certPem)
@@ -154,7 +154,7 @@ func TestRuntime_TLSAuthConfigWithLoadedCA(t *testing.T) {
 }
 
 func TestRuntime_TLSAuthConfigWithLoadedCAPool(t *testing.T) {
-	certPem, err := ioutil.ReadFile("../fixtures/certs/myCA.crt")
+	certPem, err := os.ReadFile("../fixtures/certs/myCA.crt")
 	require.NoError(t, err)
 
 	block, _ := pem.Decode(certPem)
@@ -176,13 +176,13 @@ func TestRuntime_TLSAuthConfigWithLoadedCAPool(t *testing.T) {
 
 			// Using require.Len prints the (very large and incomprehensible)
 			// Subjects list on failure, so instead use require.Equal.
-			require.Equal(t, 1, len(cfg.RootCAs.Subjects()))
+			require.Equal(t, 1, len(cfg.RootCAs.Subjects())) // nolint:staticcheck
 		}
 	}
 }
 
 func TestRuntime_TLSAuthConfigWithLoadedCAPoolAndLoadedCA(t *testing.T) {
-	certPem, err := ioutil.ReadFile("../fixtures/certs/myCA.crt")
+	certPem, err := os.ReadFile("../fixtures/certs/myCA.crt")
 	require.NoError(t, err)
 
 	block, _ := pem.Decode(certPem)
@@ -199,7 +199,7 @@ func TestRuntime_TLSAuthConfigWithLoadedCAPoolAndLoadedCA(t *testing.T) {
 		pool, err = x509.SystemCertPool()
 		require.NoError(t, err)
 	}
-	startingCertCount := len(pool.Subjects())
+	startingCertCount := len(pool.Subjects()) // nolint:staticcheck
 
 	var opts TLSClientOptions
 	opts.LoadedCAPool = pool
@@ -212,7 +212,7 @@ func TestRuntime_TLSAuthConfigWithLoadedCAPoolAndLoadedCA(t *testing.T) {
 
 			// Using require.Len prints the (very large and incomprehensible)
 			// Subjects list on failure, so instead use require.Equal.
-			require.Equal(t, startingCertCount+1, len(cfg.RootCAs.Subjects()))
+			require.Equal(t, startingCertCount+1, len(cfg.RootCAs.Subjects())) // nolint:staticcheck
 		}
 	}
 }
@@ -251,7 +251,7 @@ func TestRuntime_ManualCertificateValidation(t *testing.T) {
 
 	// root cert
 	rootCertFile := "../fixtures/certs/myCA.crt"
-	rootCertPem, err := ioutil.ReadFile(rootCertFile)
+	rootCertPem, err := os.ReadFile(rootCertFile)
 	require.NoError(t, err)
 	rootCertRaw, _ := pem.Decode(rootCertPem)
 	require.NotNil(t, rootCertRaw)
@@ -623,7 +623,7 @@ func TestRuntime_CustomTransport(t *testing.T) {
 		buf := bytes.NewBuffer(nil)
 		enc := json.NewEncoder(buf)
 		_ = enc.Encode(result)
-		resp.Body = ioutil.NopCloser(buf)
+		resp.Body = io.NopCloser(buf)
 		return &resp, nil
 	})
 
@@ -969,7 +969,7 @@ func (o *overrideRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
 	o.overriden = true
 	res := new(http.Response)
 	res.StatusCode = 200
-	res.Body = ioutil.NopCloser(bytes.NewBufferString("OK"))
+	res.Body = io.NopCloser(bytes.NewBufferString("OK"))
 	return res, nil
 }
 
diff --git a/client_request.go b/client_request.go
index 3efda34..d4d2b58 100644
--- a/client_request.go
+++ b/client_request.go
@@ -16,7 +16,6 @@ package runtime
 
 import (
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/url"
 	"time"
@@ -79,7 +78,7 @@ type NamedReadCloser interface {
 func NamedReader(name string, rdr io.Reader) NamedReadCloser {
 	rc, ok := rdr.(io.ReadCloser)
 	if !ok {
-		rc = ioutil.NopCloser(rdr)
+		rc = io.NopCloser(rdr)
 	}
 	return &namedReadCloser{
 		name: name,
diff --git a/client_response.go b/client_response.go
index 0b7e382..0d16911 100644
--- a/client_response.go
+++ b/client_response.go
@@ -15,10 +15,9 @@
 package runtime
 
 import (
+	"encoding/json"
 	"fmt"
 	"io"
-
-	"encoding/json"
 )
 
 // A ClientResponse represents a client response
@@ -61,13 +60,18 @@ type APIError struct {
 	Code          int
 }
 
-func (a *APIError) Error() string {
-	resp, _ := json.Marshal(a.Response)
-	return fmt.Sprintf("%s (status %d): %s", a.OperationName, a.Code, resp)
+func (o *APIError) Error() string {
+	var resp []byte
+	if err, ok := o.Response.(error); ok {
+		resp = []byte("'" + err.Error() + "'")
+	} else {
+		resp, _ = json.Marshal(o.Response)
+	}
+	return fmt.Sprintf("%s (status %d): %s", o.OperationName, o.Code, resp)
 }
 
-func (a *APIError) String() string {
-	return a.Error()
+func (o *APIError) String() string {
+	return o.Error()
 }
 
 // IsSuccess returns true when this elapse o k response returns a 2xx status code
diff --git a/client_response_test.go b/client_response_test.go
index ee13d9d..75a6758 100644
--- a/client_response_test.go
+++ b/client_response_test.go
@@ -16,8 +16,10 @@ package runtime
 
 import (
 	"bytes"
+	"errors"
 	"io"
-	"io/ioutil"
+	"io/fs"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -39,7 +41,7 @@ func (r response) GetHeaders(_ string) []string {
 	return []string{"the headers", "the headers2"}
 }
 func (r response) Body() io.ReadCloser {
-	return ioutil.NopCloser(bytes.NewBufferString("the content"))
+	return io.NopCloser(bytes.NewBufferString("the content"))
 }
 
 func TestResponseReaderFunc(t *testing.T) {
@@ -48,7 +50,7 @@ func TestResponseReaderFunc(t *testing.T) {
 		Code                  int
 	}
 	reader := ClientResponseReaderFunc(func(r ClientResponse, _ Consumer) (interface{}, error) {
-		b, _ := ioutil.ReadAll(r.Body())
+		b, _ := io.ReadAll(r.Body())
 		actual.Body = string(b)
 		actual.Code = r.Code()
 		actual.Message = r.Message()
@@ -61,3 +63,27 @@ func TestResponseReaderFunc(t *testing.T) {
 	assert.Equal(t, "the header", actual.Header)
 	assert.Equal(t, 490, actual.Code)
 }
+
+func TestResponseReaderFuncError(t *testing.T) {
+	reader := ClientResponseReaderFunc(func(r ClientResponse, _ Consumer) (interface{}, error) {
+		_, _ = io.ReadAll(r.Body())
+		return nil, NewAPIError("fake", errors.New("writer closed"), 490)
+	})
+	_, err := reader.ReadResponse(response{}, nil)
+	assert.NotNil(t, err)
+	assert.True(t, strings.Contains(err.Error(), "writer closed"), err.Error())
+
+	reader = func(r ClientResponse, _ Consumer) (interface{}, error) {
+		_, _ = io.ReadAll(r.Body())
+		err := &fs.PathError{
+			Op:   "write",
+			Path: "path/to/fake",
+			Err:  fs.ErrClosed,
+		}
+		return nil, NewAPIError("fake", err, 200)
+	}
+	_, err = reader.ReadResponse(response{}, nil)
+	assert.NotNil(t, err)
+	assert.True(t, strings.Contains(err.Error(), "file already closed"), err.Error())
+
+}
diff --git a/debian/changelog b/debian/changelog
index b9b9097..0a198c6 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+golang-github-go-openapi-runtime (0.25.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Fri, 07 Apr 2023 05:21:59 -0000
+
 golang-github-go-openapi-runtime (0.23.3-1) unstable; urgency=medium
 
   * Team upload.
diff --git a/go.mod b/go.mod
index e5db4c1..a94c638 100644
--- a/go.mod
+++ b/go.mod
@@ -1,7 +1,6 @@
 module github.com/go-openapi/runtime
 
 require (
-	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
 	github.com/docker/go-units v0.4.0
 	github.com/go-openapi/analysis v0.21.2
 	github.com/go-openapi/errors v0.20.2
@@ -10,14 +9,34 @@ require (
 	github.com/go-openapi/strfmt v0.21.2
 	github.com/go-openapi/swag v0.21.1
 	github.com/go-openapi/validate v0.21.0
+	github.com/opentracing/opentracing-go v1.2.0
+	github.com/stretchr/testify v1.8.0
+	go.opentelemetry.io/otel v1.11.1
+	go.opentelemetry.io/otel/sdk v1.11.1
+	go.opentelemetry.io/otel/trace v1.11.1
+	gopkg.in/yaml.v2 v2.4.0
+)
+
+require (
+	github.com/PuerkitoBio/purell v1.1.1 // indirect
+	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
+	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-logr/logr v1.2.3 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-openapi/jsonpointer v0.19.5 // indirect
+	github.com/go-openapi/jsonreference v0.19.6 // indirect
 	github.com/go-stack/stack v1.8.1 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mitchellh/mapstructure v1.4.3 // indirect
-	github.com/opentracing/opentracing-go v1.2.0
-	github.com/stretchr/testify v1.7.0
+	github.com/oklog/ulid v1.3.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	go.mongodb.org/mongo-driver v1.8.3 // indirect
 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
-	gopkg.in/yaml.v2 v2.4.0
+	golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
+	golang.org/x/text v0.3.7 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
-go 1.15
+go 1.18
diff --git a/go.sum b/go.sum
index c821eb7..2e043cb 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
 github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
+github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-openapi/analysis v0.21.2 h1:hXFrOYFHUAMQdu6zwAiKKJHJQ8kqZs1ux/ru1P1wLJU=
 github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY=
 github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
@@ -65,8 +70,8 @@ github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGt
 github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
 github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -117,11 +122,14 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
@@ -132,6 +140,12 @@ go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R7
 go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng=
 go.mongodb.org/mongo-driver v1.8.3 h1:TDKlTkGDKm9kkJVUOAXDK5/fkqKHJVwYQSpoRfB43R4=
 go.mongodb.org/mongo-driver v1.8.3/go.mod h1:0sQWfOeY63QTntERDJJ/0SuKK0T1uVSgKCuAROlKEPY=
+go.opentelemetry.io/otel v1.11.1 h1:4WLLAmcfkmDk2ukNXJyq3/kiz/3UzCaYq6PskJsaou4=
+go.opentelemetry.io/otel v1.11.1/go.mod h1:1nNhXBbWSD0nsL38H6btgnFN2k4i0sNLHNNMZMSbUGE=
+go.opentelemetry.io/otel/sdk v1.11.1 h1:F7KmQgoHljhUuJyA+9BiU+EkJfyX5nVVF4wyzWZpKxs=
+go.opentelemetry.io/otel/sdk v1.11.1/go.mod h1:/l3FE4SupHJ12TduVjUkZtlfFqDCQJlOlithYrdktys=
+go.opentelemetry.io/otel/trace v1.11.1 h1:ofxdnzsNrGBYXbP7t7zpUK281+go5rF7dvdIZXF8gdQ=
+go.opentelemetry.io/otel/trace v1.11.1/go.mod h1:f/Q9G7vzk5u91PhbmKbg1Qn0rzH1LJ4vbPHFGkTPtOk=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
@@ -156,11 +170,10 @@ golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
+golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -171,7 +184,6 @@ golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3
 golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -185,5 +197,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/middleware/context.go b/middleware/context.go
index 250e35f..d21ae4e 100644
--- a/middleware/context.go
+++ b/middleware/context.go
@@ -195,6 +195,17 @@ func NewRoutableContext(spec *loads.Document, routableAPI RoutableAPI, routes Ro
 	if spec != nil {
 		an = analysis.New(spec.Spec())
 	}
+
+	return NewRoutableContextWithAnalyzedSpec(spec, an, routableAPI, routes)
+}
+
+// NewRoutableContextWithAnalyzedSpec is like NewRoutableContext but takes in input the analysed spec too
+func NewRoutableContextWithAnalyzedSpec(spec *loads.Document, an *analysis.Spec, routableAPI RoutableAPI, routes Router) *Context {
+	// Either there are no spec doc and analysis, or both of them.
+	if !((spec == nil && an == nil) || (spec != nil && an != nil)) {
+		panic(errors.New(http.StatusInternalServerError, "routable context requires either both spec doc and analysis, or none of them"))
+	}
+
 	ctx := &Context{spec: spec, api: routableAPI, analyzer: an, router: routes}
 	return ctx
 }
@@ -498,7 +509,9 @@ func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []st
 
 	if resp, ok := data.(Responder); ok {
 		producers := route.Producers
-		prod, ok := producers[format]
+		// 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()]
diff --git a/middleware/denco/server_test.go b/middleware/denco/server_test.go
index 4ef43e4..a256284 100644
--- a/middleware/denco/server_test.go
+++ b/middleware/denco/server_test.go
@@ -2,7 +2,7 @@ package denco_test
 
 import (
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -59,7 +59,7 @@ func TestMux(t *testing.T) {
 			continue
 		}
 		defer res.Body.Close()
-		body, err := ioutil.ReadAll(res.Body)
+		body, err := io.ReadAll(res.Body)
 		if err != nil {
 			t.Error(err)
 			continue
@@ -94,7 +94,7 @@ func TestNotFound(t *testing.T) {
 		t.Fatal(err)
 	}
 	defer res.Body.Close()
-	body, err := ioutil.ReadAll(res.Body)
+	body, err := io.ReadAll(res.Body)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/middleware/parameter.go b/middleware/parameter.go
index 8fa0cf4..9aaf659 100644
--- a/middleware/parameter.go
+++ b/middleware/parameter.go
@@ -206,7 +206,11 @@ func (p *untypedParamBinder) Bind(request *http.Request, routeParams RouteParams
 		if p.parameter.Type == "file" {
 			file, header, ffErr := request.FormFile(p.parameter.Name)
 			if ffErr != nil {
-				return errors.NewParseError(p.Name, p.parameter.In, "", ffErr)
+				if p.parameter.Required {
+					return errors.NewParseError(p.Name, p.parameter.In, "", ffErr)
+				} else {
+					return nil
+				}
 			}
 			target.Set(reflect.ValueOf(runtime.File{Data: file, Header: header}))
 			return nil
diff --git a/middleware/request_test.go b/middleware/request_test.go
index 0c424a8..65ef69a 100644
--- a/middleware/request_test.go
+++ b/middleware/request_test.go
@@ -17,7 +17,6 @@ package middleware
 import (
 	"bytes"
 	"io"
-	"io/ioutil"
 	"mime/multipart"
 	"net/http"
 	"net/url"
@@ -406,7 +405,7 @@ type fileRequest struct {
 func paramsForFileUpload() *UntypedRequestBinder {
 	nameParam := spec.FormDataParam("name").Typed("string", "")
 
-	fileParam := spec.FileParam("file")
+	fileParam := spec.FileParam("file").AsRequired()
 
 	params := map[string]spec.Parameter{"Name": *nameParam, "File": *fileParam}
 	return NewUntypedRequestBinder(params, new(spec.Swagger), strfmt.Default)
@@ -435,7 +434,7 @@ func TestBindingFileUpload(t *testing.T) {
 	assert.NotNil(t, data.File.Header)
 	assert.Equal(t, "plain-jane.txt", data.File.Header.Filename)
 
-	bb, err := ioutil.ReadAll(data.File.Data)
+	bb, err := io.ReadAll(data.File.Data)
 	assert.NoError(t, err)
 	assert.Equal(t, []byte("the file contents"), bb)
 
@@ -469,4 +468,63 @@ func TestBindingFileUpload(t *testing.T) {
 
 	data = fileRequest{}
 	assert.Error(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+
+	writer = multipart.NewWriter(body)
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	req, _ = http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+
+	data = fileRequest{}
+	assert.Error(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+}
+
+func paramsForOptionalFileUpload() *UntypedRequestBinder {
+	nameParam := spec.FormDataParam("name").Typed("string", "")
+	fileParam := spec.FileParam("file").AsOptional()
+
+	params := map[string]spec.Parameter{"Name": *nameParam, "File": *fileParam}
+	return NewUntypedRequestBinder(params, new(spec.Swagger), strfmt.Default)
+}
+
+func TestBindingOptionalFileUpload(t *testing.T) {
+	binder := paramsForOptionalFileUpload()
+
+	body := bytes.NewBuffer(nil)
+	writer := multipart.NewWriter(body)
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	urlStr := "http://localhost:8002/hello"
+	req, _ := http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+
+	data := fileRequest{}
+	assert.NoError(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+	assert.Equal(t, "the-name", data.Name)
+	assert.Nil(t, data.File.Data)
+	assert.Nil(t, data.File.Header)
+
+	writer = multipart.NewWriter(body)
+	part, err := writer.CreateFormFile("file", "plain-jane.txt")
+	assert.NoError(t, err)
+	_, _ = part.Write([]byte("the file contents"))
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	req, _ = http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	assert.NoError(t, writer.Close())
+
+	data = fileRequest{}
+	assert.NoError(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+	assert.Equal(t, "the-name", data.Name)
+	assert.NotNil(t, data.File)
+	assert.NotNil(t, data.File.Header)
+	assert.Equal(t, "plain-jane.txt", data.File.Header.Filename)
+
+	bb, err := io.ReadAll(data.File.Data)
+	assert.NoError(t, err)
+	assert.Equal(t, []byte("the file contents"), bb)
 }
diff --git a/middleware/swaggerui.go b/middleware/swaggerui.go
index 2c92f5c..b4dea29 100644
--- a/middleware/swaggerui.go
+++ b/middleware/swaggerui.go
@@ -16,6 +16,8 @@ type SwaggerUIOpts struct {
 	Path string
 	// SpecURL the url to find the spec for
 	SpecURL string
+	// OAuthCallbackURL the url called after OAuth2 login
+	OAuthCallbackURL string
 
 	// The three components needed to embed swagger-ui
 	SwaggerURL       string
@@ -40,6 +42,9 @@ func (r *SwaggerUIOpts) EnsureDefaults() {
 	if r.SpecURL == "" {
 		r.SpecURL = "/swagger.json"
 	}
+	if r.OAuthCallbackURL == "" {
+		r.OAuthCallbackURL = path.Join(r.BasePath, r.Path, "oauth2-callback")
+	}
 	if r.SwaggerURL == "" {
 		r.SwaggerURL = swaggerLatest
 	}
@@ -73,7 +78,7 @@ func SwaggerUI(opts SwaggerUIOpts, next http.Handler) http.Handler {
 	b := buf.Bytes()
 
 	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == pth {
+		if path.Join(r.URL.Path) == pth {
 			rw.Header().Set("Content-Type", "text/html; charset=utf-8")
 			rw.WriteHeader(http.StatusOK)
 
@@ -149,7 +154,8 @@ const (
         plugins: [
           SwaggerUIBundle.plugins.DownloadUrl
         ],
-        layout: "StandaloneLayout"
+        layout: "StandaloneLayout",
+		oauth2RedirectUrl: '{{ .OAuthCallbackURL }}'
       })
       // End Swagger UI call region
 
diff --git a/middleware/swaggerui_oauth2.go b/middleware/swaggerui_oauth2.go
new file mode 100644
index 0000000..576f600
--- /dev/null
+++ b/middleware/swaggerui_oauth2.go
@@ -0,0 +1,122 @@
+package middleware
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"path"
+	"text/template"
+)
+
+func SwaggerUIOAuth2Callback(opts SwaggerUIOpts, next http.Handler) http.Handler {
+	opts.EnsureDefaults()
+
+	pth := opts.OAuthCallbackURL
+	tmpl := template.Must(template.New("swaggeroauth").Parse(swaggerOAuthTemplate))
+
+	buf := bytes.NewBuffer(nil)
+	_ = tmpl.Execute(buf, &opts)
+	b := buf.Bytes()
+
+	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		if path.Join(r.URL.Path) == pth {
+			rw.Header().Set("Content-Type", "text/html; charset=utf-8")
+			rw.WriteHeader(http.StatusOK)
+
+			_, _ = rw.Write(b)
+			return
+		}
+
+		if next == nil {
+			rw.Header().Set("Content-Type", "text/plain")
+			rw.WriteHeader(http.StatusNotFound)
+			_, _ = rw.Write([]byte(fmt.Sprintf("%q not found", pth)))
+			return
+		}
+		next.ServeHTTP(rw, r)
+	})
+}
+
+const (
+	swaggerOAuthTemplate = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>{{ .Title }}</title>
+</head>
+<body>
+<script>
+    'use strict';
+    function run () {
+        var oauth2 = window.opener.swaggerUIRedirectOauth2;
+        var sentState = oauth2.state;
+        var redirectUrl = oauth2.redirectUrl;
+        var isValid, qp, arr;
+
+        if (/code|token|error/.test(window.location.hash)) {
+            qp = window.location.hash.substring(1).replace('?', '&');
+        } else {
+            qp = location.search.substring(1);
+        }
+
+        arr = qp.split("&");
+        arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
+        qp = qp ? JSON.parse('{' + arr.join() + '}',
+                function (key, value) {
+                    return key === "" ? value : decodeURIComponent(value);
+                }
+        ) : {};
+
+        isValid = qp.state === sentState;
+
+        if ((
+          oauth2.auth.schema.get("flow") === "accessCode" ||
+          oauth2.auth.schema.get("flow") === "authorizationCode" ||
+          oauth2.auth.schema.get("flow") === "authorization_code"
+        ) && !oauth2.auth.code) {
+            if (!isValid) {
+                oauth2.errCb({
+                    authId: oauth2.auth.name,
+                    source: "auth",
+                    level: "warning",
+                    message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
+                });
+            }
+
+            if (qp.code) {
+                delete oauth2.state;
+                oauth2.auth.code = qp.code;
+                oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
+            } else {
+                let oauthErrorMsg;
+                if (qp.error) {
+                    oauthErrorMsg = "["+qp.error+"]: " +
+                        (qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
+                        (qp.error_uri ? "More info: "+qp.error_uri : "");
+                }
+
+                oauth2.errCb({
+                    authId: oauth2.auth.name,
+                    source: "auth",
+                    level: "error",
+                    message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
+                });
+            }
+        } else {
+            oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
+        }
+        window.close();
+    }
+
+    if (document.readyState !== 'loading') {
+        run();
+    } else {
+        document.addEventListener('DOMContentLoaded', function () {
+            run();
+        });
+    }
+</script>
+</body>
+</html>
+`
+)
diff --git a/middleware/swaggerui_oauth2_test.go b/middleware/swaggerui_oauth2_test.go
new file mode 100644
index 0000000..eab1639
--- /dev/null
+++ b/middleware/swaggerui_oauth2_test.go
@@ -0,0 +1,20 @@
+package middleware
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSwaggerUIOAuth2CallbackMiddleware(t *testing.T) {
+	redoc := SwaggerUIOAuth2Callback(SwaggerUIOpts{}, nil)
+
+	req, _ := http.NewRequest("GET", "/docs/oauth2-callback", nil)
+	recorder := httptest.NewRecorder()
+	redoc.ServeHTTP(recorder, req)
+	assert.Equal(t, 200, recorder.Code)
+	assert.Equal(t, "text/html; charset=utf-8", recorder.Header().Get("Content-Type"))
+	assert.Contains(t, recorder.Body.String(), "<title>API documentation</title>")
+}
diff --git a/middleware/untyped_request_test.go b/middleware/untyped_request_test.go
index e44249d..f156f35 100644
--- a/middleware/untyped_request_test.go
+++ b/middleware/untyped_request_test.go
@@ -18,7 +18,7 @@ import (
 	"bytes"
 	"encoding/base64"
 	"encoding/json"
-	"io/ioutil"
+	"io"
 	"mime/multipart"
 	"net/http"
 	"net/url"
@@ -77,7 +77,7 @@ func TestUntypedFileUpload(t *testing.T) {
 	assert.NotNil(t, file.Header)
 	assert.Equal(t, "plain-jane.txt", file.Header.Filename)
 
-	bb, err := ioutil.ReadAll(file.Data)
+	bb, err := io.ReadAll(file.Data)
 	assert.NoError(t, err)
 	assert.Equal(t, []byte("the file contents"), bb)
 
@@ -111,6 +111,54 @@ func TestUntypedFileUpload(t *testing.T) {
 
 	data = make(map[string]interface{})
 	assert.Error(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+
+	writer = multipart.NewWriter(body)
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	req, _ = http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+
+	data = make(map[string]interface{})
+	assert.Error(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+}
+
+func TestUntypedOptionalFileUpload(t *testing.T) {
+	binder := paramsForOptionalFileUpload()
+
+	body := bytes.NewBuffer(nil)
+	writer := multipart.NewWriter(body)
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	urlStr := "http://localhost:8002/hello"
+	req, _ := http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+
+	data := make(map[string]interface{})
+	assert.NoError(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+	assert.Equal(t, "the-name", data["name"])
+
+	writer = multipart.NewWriter(body)
+	part, err := writer.CreateFormFile("file", "plain-jane.txt")
+	assert.NoError(t, err)
+	_, _ = part.Write([]byte("the file contents"))
+	_ = writer.WriteField("name", "the-name")
+	assert.NoError(t, writer.Close())
+
+	req, _ = http.NewRequest("POST", urlStr, body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	assert.NoError(t, writer.Close())
+
+	data = make(map[string]interface{})
+	assert.NoError(t, binder.Bind(req, nil, runtime.JSONConsumer(), &data))
+	assert.Equal(t, "the-name", data["name"])
+	assert.NotNil(t, data["file"])
+	assert.IsType(t, runtime.File{}, data["file"])
+	file := data["file"].(runtime.File)
+	assert.NotNil(t, file.Header)
+	assert.Equal(t, "plain-jane.txt", file.Header.Filename)
+
 }
 
 func TestUntypedBindingTypesForValid(t *testing.T) {
diff --git a/request_test.go b/request_test.go
index 3850071..7dec2ae 100644
--- a/request_test.go
+++ b/request_test.go
@@ -18,7 +18,6 @@ import (
 	"bufio"
 	"bytes"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/url"
 	"strings"
@@ -89,7 +88,7 @@ func TestPeekingReader(t *testing.T) {
 	// just passes to original reader when nothing called
 	exp1 := []byte("original")
 	pr1 := newPeekingReader(closeReader(bytes.NewReader(exp1)))
-	b1, err := ioutil.ReadAll(pr1)
+	b1, err := io.ReadAll(pr1)
 	if assert.NoError(t, err) {
 		assert.Equal(t, exp1, b1)
 	}
@@ -100,7 +99,7 @@ func TestPeekingReader(t *testing.T) {
 	peeked, err := pr2.underlying.Peek(1)
 	require.NoError(t, err)
 	require.Equal(t, "a", string(peeked))
-	b2, err := ioutil.ReadAll(pr2)
+	b2, err := io.ReadAll(pr2)
 	if assert.NoError(t, err) {
 		assert.Equal(t, string(exp2), string(b2))
 	}
@@ -132,7 +131,7 @@ func TestPeekingReader(t *testing.T) {
 	require.Equal(t, 1, cbr.peeks)
 	require.Equal(t, 0, cbr.reads)
 
-	b, err := ioutil.ReadAll(pr)
+	b, err := io.ReadAll(pr)
 	require.NoError(t, err)
 	require.Equal(t, "hello", string(b))
 	require.Equal(t, 2, cbr.buffereds)

More details

Full run details

Historical runs