mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
feat: the doc comment IS the spec — descriptions, field docs and examples
A spec with no descriptions and no examples is a reference someone reads, not
one they can press "try it" on, and that gap is most of what an API portal is
for. This closes it without anyone writing anything twice.
Go does not retain doc comments at run time — reflection sees types and struct
tags, never comments — so prose can only reach the spec through a build-time
pass over the AST. That constraint is the whole design. The alternative is
WithSummary("…") sitting directly under a doc comment that says the same thing:
two places to change, one to forget.
cmd/zipdoc (next) walks a package and emits Describe calls carrying the handler's
doc comment, each In/Out field's comment, and the Example/Response bodies written
in that comment. buildOpenAPI merges them: operation description, per-field
descriptions keyed by Type.field so an In and an Out "limit" do not collide, and
request/response examples emitted as raw JSON so a malformed one is wrong loudly.
The summary falls back to the comment's first sentence, because Go's own
convention already makes that a summary — so nobody writes a summary at all.
Degrades cleanly: with no generated file the spec is exactly what it was, schemas
without prose. Nothing breaks by not running the generator; it just stays terse.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package zip
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Documentation comes from the source, because the source is where it is already
|
||||
// written.
|
||||
//
|
||||
// Go does not retain doc comments at run time — reflection sees types and struct
|
||||
// tags, never comments — so an operation's prose can only reach the spec through
|
||||
// a build-time pass over the AST. That is the whole reason this package exists:
|
||||
// without it the only runtime option is WithSummary("…") sitting directly under a
|
||||
// doc comment that says the same thing, which is two places to change and one to
|
||||
// forget.
|
||||
//
|
||||
// cmd/zipdoc walks a package, reads the doc comment on each typed handler and on
|
||||
// each field of its In and Out types, and emits a file that calls Describe. The
|
||||
// comment IS the description; nothing is written twice.
|
||||
//
|
||||
// // ListInvoices returns every invoice for the caller's org, newest first.
|
||||
// //
|
||||
// // Example: {"limit": 25}
|
||||
// // Response: {"invoices": [{"id": "inv_1", "cents": 1200}]}
|
||||
// func ListInvoices(ctx context.Context, in *ListIn) (*ListOut, error)
|
||||
//
|
||||
// Examples are part of the comment rather than a struct tag for the same reason:
|
||||
// one mechanism, one place. A spec without examples is a spec nobody can try, and
|
||||
// "try it" is most of what an interactive doc is for.
|
||||
|
||||
// Doc is what cmd/zipdoc extracted for one operation.
|
||||
type Doc struct {
|
||||
// Description is the handler's doc comment, minus any Example/Response lines.
|
||||
Description string
|
||||
|
||||
// Fields maps a JSON field name to its doc comment, for both In and Out.
|
||||
// Keyed by "TypeName.jsonField" so an In and an Out field of the same name
|
||||
// do not collide.
|
||||
Fields map[string]string
|
||||
|
||||
// Example and Response are the request and response bodies from the comment.
|
||||
// Raw JSON so they land in the spec exactly as written and are wrong loudly
|
||||
// rather than quietly if malformed.
|
||||
Example json.RawMessage
|
||||
Response json.RawMessage
|
||||
}
|
||||
|
||||
// docs is the process-wide extraction, keyed by "METHOD /path" — the same
|
||||
// identity the route registry uses, so a doc with no matching route is a
|
||||
// generator bug that shows up as an unused entry rather than a silent mismatch.
|
||||
var docs = map[string]Doc{}
|
||||
|
||||
// Describe records documentation for one operation. Generated code calls it from
|
||||
// an init(); hand-written calls are possible but defeat the point, since the
|
||||
// comment is then no longer the single source.
|
||||
func Describe(methodPath string, d Doc) { docs[methodPath] = d }
|
||||
|
||||
// docFor returns the extraction for an operation, if cmd/zipdoc ran.
|
||||
func docFor(method, path string) (Doc, bool) {
|
||||
d, ok := docs[method+" "+path]
|
||||
return d, ok
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type invIn struct {
|
||||
Limit int `json:"limit"`
|
||||
Org string `json:"org" validate:"required"`
|
||||
}
|
||||
type invOut struct {
|
||||
Invoices []string `json:"invoices"`
|
||||
}
|
||||
|
||||
// TestDoc_ReachesTheSpec proves an extracted doc comment becomes the operation's
|
||||
// description, its fields' descriptions, and its request/response examples —
|
||||
// i.e. that the comment IS the spec rather than something written twice.
|
||||
func TestDoc_ReachesTheSpec(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
|
||||
// What cmd/zipdoc emits for this operation.
|
||||
zip.Describe("POST /v1/billing/invoices", zip.Doc{
|
||||
Description: "ListInvoices returns every invoice for the caller's org, newest first. Results are capped by limit.",
|
||||
Fields: map[string]string{
|
||||
"invIn.limit": "Maximum invoices to return.",
|
||||
"invIn.org": "Org whose invoices to list.",
|
||||
"invOut.invoices": "Invoice ids, newest first.",
|
||||
},
|
||||
Example: json.RawMessage(`{"limit":25,"org":"hanzo"}`),
|
||||
Response: json.RawMessage(`{"invoices":["inv_2","inv_1"]}`),
|
||||
})
|
||||
zip.Post(app, "/v1/billing/invoices", func(_ context.Context, in *invIn) (*invOut, error) {
|
||||
return &invOut{}, nil
|
||||
})
|
||||
|
||||
spec := app.OpenAPISpec()
|
||||
raw, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal spec: %v", err)
|
||||
}
|
||||
got := string(raw)
|
||||
|
||||
for _, want := range []string{
|
||||
`newest first`, // description
|
||||
`Maximum invoices to return.`, // In field doc
|
||||
`Invoice ids, newest first.`, // Out field doc
|
||||
`"limit":25`, // request example
|
||||
`"invoices":["inv_2","inv_1"]`, // response example
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("spec is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The summary falls back to the comment's first sentence, which Go's own
|
||||
// convention already makes a summary — so nobody writes it twice.
|
||||
ops := spec["paths"].(map[string]map[string]any)["/v1/billing/invoices"]
|
||||
post := ops["post"].(map[string]any)
|
||||
if s, _ := post["summary"].(string); !strings.HasPrefix(s, "ListInvoices returns") || strings.Contains(s, "capped by limit") {
|
||||
t.Errorf("summary = %q, want just the first sentence", s)
|
||||
}
|
||||
}
|
||||
+66
-15
@@ -1,6 +1,7 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -84,6 +85,19 @@ func (a *App) buildOpenAPI() map[string]any {
|
||||
"operationId": op.OperationID,
|
||||
"summary": op.Summary,
|
||||
}
|
||||
// Prose and examples extracted from the source by cmd/zipdoc. Absent
|
||||
// when the generator has not run, which degrades to the schema-only
|
||||
// spec rather than failing — a spec without descriptions is still a
|
||||
// usable spec.
|
||||
doc, hasDoc := docFor(op.Method, op.Path)
|
||||
if hasDoc {
|
||||
if doc.Description != "" {
|
||||
opObj["description"] = doc.Description
|
||||
}
|
||||
if op.Summary == "" {
|
||||
opObj["summary"] = firstSentence(doc.Description)
|
||||
}
|
||||
}
|
||||
if op.OperationID == "" {
|
||||
opObj["operationId"] = defaultOpID(op.Method, op.Path)
|
||||
}
|
||||
@@ -95,14 +109,17 @@ func (a *App) buildOpenAPI() map[string]any {
|
||||
if op.Method != "GET" && op.Method != "HEAD" && op.Method != "DELETE" {
|
||||
inName := typeName(op.InType)
|
||||
if op.InType != nil && inName != "" {
|
||||
schemas[inName] = schemaOf(op.InType, schemas)
|
||||
schemas[inName] = schemaOfDoc(op.InType, schemas, docFields(hasDoc, doc))
|
||||
media := map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/" + inName}}
|
||||
// An example is what makes a spec explorable — it is the
|
||||
// difference between a reference someone reads and one they can
|
||||
// press "try it" on.
|
||||
if hasDoc && len(doc.Example) > 0 {
|
||||
media["example"] = json.RawMessage(doc.Example)
|
||||
}
|
||||
opObj["requestBody"] = map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{"$ref": "#/components/schemas/" + inName},
|
||||
},
|
||||
},
|
||||
"content": map[string]any{"application/json": media},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,15 +142,15 @@ func (a *App) buildOpenAPI() map[string]any {
|
||||
// 200 response.
|
||||
outName := typeName(op.OutType)
|
||||
if op.OutType != nil && outName != "" {
|
||||
schemas[outName] = schemaOf(op.OutType, schemas)
|
||||
schemas[outName] = schemaOfDoc(op.OutType, schemas, docFields(hasDoc, doc))
|
||||
respMedia := map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/" + outName}}
|
||||
if hasDoc && len(doc.Response) > 0 {
|
||||
respMedia["example"] = json.RawMessage(doc.Response)
|
||||
}
|
||||
opObj["responses"] = map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "ok",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{"$ref": "#/components/schemas/" + outName},
|
||||
},
|
||||
},
|
||||
"content": map[string]any{"application/json": respMedia},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@@ -205,6 +222,13 @@ func typeName(t reflect.Type) string {
|
||||
// schemaOf builds a minimal JSON Schema for t. Handles structs, primitives,
|
||||
// slices, and pointers. Anonymous types become "object" without a ref.
|
||||
func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
|
||||
return schemaOfDoc(t, registry, nil)
|
||||
}
|
||||
|
||||
// schemaOfDoc is schemaOf with the extraction in hand, so a field's doc comment
|
||||
// becomes its description. Keyed by "Type.field" because an In and an Out can
|
||||
// both have a "limit" and they are not the same thing.
|
||||
func schemaOfDoc(t reflect.Type, registry map[string]any, fields map[string]string) map[string]any {
|
||||
if t == nil {
|
||||
return map[string]any{"type": "object"}
|
||||
}
|
||||
@@ -224,12 +248,12 @@ func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
|
||||
case reflect.Slice, reflect.Array:
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"items": schemaOf(t.Elem(), registry),
|
||||
"items": schemaOfDoc(t.Elem(), registry, fields),
|
||||
}
|
||||
case reflect.Map:
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": schemaOf(t.Elem(), registry),
|
||||
"additionalProperties": schemaOfDoc(t.Elem(), registry, fields),
|
||||
}
|
||||
case reflect.Struct:
|
||||
props := map[string]any{}
|
||||
@@ -243,7 +267,13 @@ func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
props[name] = schemaOf(f.Type, registry)
|
||||
fs := schemaOfDoc(f.Type, registry, fields)
|
||||
if fields != nil {
|
||||
if d := fields[t.Name()+"."+name]; d != "" {
|
||||
fs["description"] = d
|
||||
}
|
||||
}
|
||||
props[name] = fs
|
||||
if tag := f.Tag.Get("validate"); strings.Contains(tag, "required") {
|
||||
required = append(required, name)
|
||||
}
|
||||
@@ -260,6 +290,27 @@ func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
|
||||
return map[string]any{"type": "object"}
|
||||
}
|
||||
|
||||
// firstSentence is the summary when none was given explicitly: a doc comment's
|
||||
// opening sentence is already written to be one, which is why Go documents that
|
||||
// convention in the first place.
|
||||
// docFields is the field map when the generator ran, nil otherwise.
|
||||
func docFields(has bool, d Doc) map[string]string {
|
||||
if !has {
|
||||
return nil
|
||||
}
|
||||
return d.Fields
|
||||
}
|
||||
|
||||
func firstSentence(s string) string {
|
||||
if i := strings.Index(s, ". "); i >= 0 {
|
||||
return s[:i+1]
|
||||
}
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func jsonFieldName(f reflect.StructField) string {
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "" {
|
||||
|
||||
Reference in New Issue
Block a user