Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
title: File uploads and byte streams
weight: 15
description: |
How io.Reader, multipart.File and the other stream types render — type: file
on a formData parameter, base64 bytes everywhere else — and how to say what
the bytes actually are.
---

A Go type like `io.Reader` says that bytes will flow. It says nothing about
*what* they are, how they are framed, or how long they run. codescan recognizes
these types and answers with the only two things Swagger 2.0 lets it say about
opaque bytes — picked by **where the field sits**, not by anything in the
declaration.

## The two answers

| Position | Rendering |
|---|---|
| `in: formData` parameter | `type: file` |
| model field, body, response body, header, other parameters | `{type: string, format: byte}` |

`type: file` is the canonical upload shape, and formData is the only location
Swagger 2.0 permits it in. Everywhere else the bytes travel inside a JSON
document, which cannot carry raw octets — so they render as `format: byte`, the
base64-encoded string the specification defines for exactly this.

## Uploading a file

Put the stream in a `formData` parameter and consume `multipart/form-data`:

{{< example go="shaping/streams/streams.go" goregion="params" golabel="parameters"
json="shaping/streams/testdata/upload_params.json" jsonlabel="parameters" >}}

`upload` becomes `type: file`; the sibling `caption` is an ordinary form field.
`multipart.File` and `io.Reader` are interchangeable here — both are recognized.

## Streams in a model or a body

Anywhere that is not a formData parameter, the same types render as base64
bytes:

{{< example go="shaping/streams/streams.go" goregion="model" golabel="model"
json="shaping/streams/testdata/attachment.json" jsonlabel="#/definitions/Attachment" >}}

`content` and `thumbnail` carry `{string, byte}`. `checksum` carries
`swagger:strfmt base64`, and the annotation wins — which is the point of the
next section.

## Say what the bytes are

The default is deliberately uninformative, because a stream *is*
uninformative. When you know more, say so and codescan will step aside:

- [`swagger:strfmt`]({{% relref "forcing-a-format" %}}) — name the format
(`base64`, `binary`, a custom one);
- [`swagger:type`]({{% relref "/maintainers/annotations/swagger-type" %}}) —
override the type outright;
- [`swagger:file`]({{% relref "/maintainers/annotations/swagger-file" %}}) —
force the file shape where you want it and the position allows it.

## What is recognized

| Package | Types |
|---|---|
| `io` | `Reader`, `ReadCloser`, `ReadSeeker`, `ReadSeekCloser`, `ReadWriter`, `ReaderAt`, `ReaderFrom`, `LimitedReader`, `ByteReader`, `ByteScanner` |
| `mime/multipart` | `File` |
| `github.com/go-openapi/runtime` | `NamedReadCloser` |

Recognition is by **identity** — the exact named type — never by shape. An
interface of your own that happens to have a `Read` method is *your* type and is
documented as you declared it.

Because both renderings erase *which* stream it was — every type in the table
produces the same schema — each one also carries an
[`x-go-type`]({{% relref "vendor-extensions" %}}) extension naming the Go type it
came from, so a consumer can tell an `io.Reader` field from a `multipart.File`
one. `SkipExtensions` suppresses it along with the rest of the `x-go-*` family.

{{% notice style="note" %}}
**`io.Writer` is not recognized**, nor are the write-only closers. A sink the
caller writes into is not something that travels on the wire, so codescan does
not assume what you meant by putting one in an API type — it documents the type
structurally, and you override it if you had something in mind.
{{% /notice %}}

## What's next

- [Forcing a conformant format]({{% relref "forcing-a-format" %}}) — the
field-level `swagger:strfmt` used above.
- [Type discovery]({{% relref "/shaping-the-output/scope-and-discovery/type-discovery" %}}) —
how codescan decides what a Go type becomes when no recognizer applies.
- [`swagger:file` reference]({{% relref "/maintainers/annotations/swagger-file" %}}).
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,69 @@ embeds should compose; reach for the option when composition is your house style
for every plain embed.
{{% /notice %}}

## Composition needs a marshaller you write

An `allOf` says the JSON document satisfies every member at once — one flat object carrying all
their properties. Go's **default** marshaller only produces that shape by coincidence, and the
coincidence holds for exactly one case: a plain struct embed with no marshaller of its own, whose
fields Go promotes.

Step outside that case and the default rendering stops matching the spec:

- a member that is **not a struct** — a map, a slice, a named basic — promotes nothing, so Go emits
it as one key named after the type instead of merging it;
- a member with **its own `MarshalJSON`/`MarshalText`** is promoted into your type's method set, and
`json.Marshal` then consults it *before* reading any field — rendering the whole struct as whatever
that method returns.

This is why go-swagger's generated models never rely on the default. A model with `allOf` embeds its
members **and** carries a hand-written pair that flattens them, reading every member from the same
raw document:

```go
// swagger:model WithAllOf
type WithAllOf struct {
Notable // an allOf member

AO1 map[string]int32 `json:"-"` // a map member — json:"-" keeps the default out of the way

WithAllOfAO2P2 // another member

Body string `json:"body,omitempty"` // the model's own fields
Title string `json:"title,omitempty"`
}

// UnmarshalJSON reads every member from the SAME document — that is what allOf means.
func (m *WithAllOf) UnmarshalJSON(raw []byte) error {
var aO0 Notable
if err := jsonutils.ReadJSON(raw, &aO0); err != nil {
return err
}
m.Notable = aO0

var aO1 map[string]int32
if err := jsonutils.ReadJSON(raw, &aO1); err != nil {
return err
}
m.AO1 = aO1

// … one block per member, then the model's own fields
}
```

{{% notice style="warning" %}}
If you hand-write the Go types that codescan scans, `swagger:allOf` describes your **intent**; it
does not make `encoding/json` produce that document. Write the marshaller, or generate the model
from the spec and let go-swagger write it for you. codescan reads declarations — it cannot tell
whether the marshalling you need exists, so it will not warn you.
{{% /notice %}}

Because of this, codescan reads an embed as *composition* and never as an instruction about the
default marshaller. In particular, a promoted `MarshalText`/`MarshalJSON` on an embedded type is
**not** treated as a claim that the whole model is a scalar — see
[Forcing a conformant format]({{% relref "forcing-a-format" %}}) if you want a type rendered as
one.

## Annotate the embedded type, not the embed

A classifier annotation in an **embedded field's** doc comment does nothing.
Expand Down
73 changes: 73 additions & 0 deletions docs/examples/shaping/streams/streams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: Apache-2.0

// Package streams holds the annotated declarations for the "File uploads and byte
// streams" how-to. streams_test.go scans it and writes the goldens the guide
// renders.
package streams

import (
"io"
"mime/multipart"
)

// snippet:model

// Attachment carries opaque byte streams as model fields.
//
// A stream says nothing about its own framing, so codescan does not invent one:
// each field renders as `{string, format: byte}` — the base64-encoded string
// Swagger 2.0 uses for arbitrary bytes.
//
// swagger:model
type Attachment struct {
// Content is the attachment payload.
Content io.Reader `json:"content"`

// Thumbnail is a closeable stream; the same answer applies.
Thumbnail io.ReadCloser `json:"thumbnail"`

// Checksum says what its bytes are, so the annotation wins over the default.
//
// swagger:strfmt base64
Checksum io.Reader `json:"checksum"`
}

// endsnippet:model

// snippet:params

// UploadParams uploads a file and its metadata.
//
// swagger:parameters uploadAttachment
type UploadParams struct {
// Upload is the file to store.
//
// in: formData
Upload multipart.File `json:"upload"`

// Caption describes the upload.
//
// in: formData
Caption string `json:"caption"`
}

// endsnippet:params

// swagger:route POST /attachments attachments uploadAttachment
//
// Uploads an attachment.
//
// Consumes:
// - multipart/form-data
//
// Responses:
//
// 200: attachmentResponse

// AttachmentResponse returns the stored attachment.
//
// swagger:response attachmentResponse
type AttachmentResponse struct {
// in: body
Body Attachment
}
94 changes: 94 additions & 0 deletions docs/examples/shaping/streams/streams_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: Apache-2.0

package streams

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/go-openapi/codescan"
"github.com/go-openapi/spec"
"github.com/go-openapi/testify/v2/assert"
"github.com/go-openapi/testify/v2/require"
)

func examplesRoot(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
}

func scanStreams(t *testing.T) *spec.Swagger {
t.Helper()
doc, err := codescan.Run(&codescan.Options{
WorkDir: examplesRoot(t),
Packages: []string{"./shaping/streams"},
ScanModels: true,
})
require.NoError(t, err)
require.NotNil(t, doc)
return doc
}

// TestStreams emits testdata/attachment.json and testdata/upload_params.json — the
// two shapes the "File uploads and byte streams" guide renders — and asserts the
// position-dependent split: `file` on a formData parameter, `{string, byte}`
// everywhere else, with an explicit annotation still winning.
//
// Regenerate with: UPDATE_GOLDEN=1 go test ./...
func TestStreams(t *testing.T) {
doc := scanStreams(t)

a, ok := doc.Definitions["Attachment"]
require.True(t, ok, "Attachment definition missing")

for _, name := range []string{"content", "thumbnail"} {
p := a.Properties[name]
assert.Equal(t, "string", p.Type[0], "%s renders as a string", name)
assert.Equal(t, "byte", p.Format, "%s carries the base64 format", name)
}

checksum := a.Properties["checksum"]
assert.Equal(t, "base64", checksum.Format, "an explicit swagger:strfmt wins over the default")

assert.NotContains(t, doc.Definitions, "Reader", "a recognized stream type is never published")
assert.NotContains(t, doc.Definitions, "ReadCloser", "a recognized stream type is never published")

require.NotNil(t, doc.Paths)
item, ok := doc.Paths.Paths["/attachments"]
require.True(t, ok, "/attachments missing")
require.NotNil(t, item.Post)

params := make(map[string]spec.Parameter, len(item.Post.Parameters))
for _, p := range item.Post.Parameters {
params[p.Name] = p
}

upload, ok := params["upload"]
require.True(t, ok, "upload parameter missing")
assert.Equal(t, "formData", upload.In)
assert.Equal(t, "file", upload.Type, "a stream in formData is the canonical upload shape")

writeGolden(t, filepath.Join("testdata", "attachment.json"), a)
writeGolden(t, filepath.Join("testdata", "upload_params.json"), item.Post.Parameters)
}

func writeGolden(t *testing.T, path string, v any) {
t.Helper()

got, err := json.MarshalIndent(v, "", " ")
require.NoError(t, err)
got = append(got, '\n')

if os.Getenv("UPDATE_GOLDEN") != "" {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
require.NoError(t, os.WriteFile(path, got, 0o600))
}
want, err := os.ReadFile(path)
require.NoError(t, err)
assert.JSONEq(t, string(want), string(got))
}
29 changes: 29 additions & 0 deletions docs/examples/shaping/streams/testdata/attachment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"description": "A stream says nothing about its own framing, so codescan does not invent one:\neach field renders as `{string, format: byte}` — the base64-encoded string\nSwagger 2.0 uses for arbitrary bytes.",
"type": "object",
"title": "Attachment carries opaque byte streams as model fields.",
"properties": {
"checksum": {
"description": "Checksum says what its bytes are, so the annotation wins over the default.",
"type": "string",
"format": "base64",
"x-go-name": "Checksum",
"x-go-type": "io.Reader"
},
"content": {
"description": "Content is the attachment payload.",
"type": "string",
"format": "byte",
"x-go-name": "Content",
"x-go-type": "io.Reader"
},
"thumbnail": {
"description": "Thumbnail is a closeable stream; the same answer applies.",
"type": "string",
"format": "byte",
"x-go-name": "Thumbnail",
"x-go-type": "io.ReadCloser"
}
},
"x-go-package": "github.com/go-openapi/codescan/docs/examples/shaping/streams"
}
17 changes: 17 additions & 0 deletions docs/examples/shaping/streams/testdata/upload_params.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{
"type": "file",
"x-go-name": "Upload",
"x-go-type": "mime/multipart.File",
"description": "Upload is the file to store.",
"name": "upload",
"in": "formData"
},
{
"type": "string",
"x-go-name": "Caption",
"description": "Caption describes the upload.",
"name": "caption",
"in": "formData"
}
]
Loading
Loading