mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
Each of these shipped into openapi.yaml and therefore into every generated SDK, and none of them could fail a build, because a wrong description is not an error. A PHANTOM REQUEST BODY. hasBody asked only the method. So POST /things/:id/verify, whose whole input binds from the path, published a requestBody marked required whose one property WAS the path param — every generated client gained an argument the caller must construct to repeat a value it already passes in the URL. Ten instances were live in the fleet's published subsets. hasRequestBody refines hasBody with the clause that was missing: the input must have at least one field the URL does not already carry. The wire is untouched — bindURL binds the path last, so a body repeating a path param never won anyway. A TYPE DESCRIBED BY ITS PARTS INSTEAD OF ITS WIRE FORM. schemaOf reached the custom-marshaler rule only inside the struct case, but the rule is about the MARSHALER. json.RawMessage is a []byte whose MarshalJSON emits raw JSON, so it fell to the slice rule and published as an array of integers — for relay ops whose answer is frequently an array or null, the document asserted a shape it can never have. time.Time fared worse: all its fields are unexported, so it published a $ref to a schema with no properties, leaving every timestamp in every SDK undescribed. The marshaler check now runs first, with time.Time named as the one marshaler whose output shape is documented (RFC 3339, format: date-time). A plain []byte still reflects as a base64 string, because it has no marshaler. A SUMMARY WITH A LINE BREAK IN IT. A doc comment wraps in Go source; a sentence ends where the next begins, whether that is a space or a newline. The summary is neither fact — it is one sentence on one line, in the spec, in CLI help, and in the first docstring line of every generated SDK. sentenceEnd now accepts a break as a terminator and the result is re-joined on single spaces. A ROUTE THAT COULD NOT BE DECLARED AT ALL. bindURL matched on the json name, so a path parameter and a body field that are the same word for two different things (a worker's name in the path, its source in the body) collided: the body field was silently overwritten by the path value, making the route structurally untypable. Fields now name themselves for the URL with `url:` and opt out with `url:"-"` — one tag answering one question, separate from what `json:` answers. The CLI reads the same tag, so the opt-out gains a flag rather than losing one. Co-authored-by: Hanzo Dev <[email protected]>
129 lines
4.9 KiB
Go
129 lines
4.9 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// relayOut carries another runtime's answer through verbatim. What comes back is
|
|
// whatever that runtime sent: an object, an array, a number, null.
|
|
type relayOut struct {
|
|
// Result is the upstream body, untouched.
|
|
Result json.RawMessage `json:"result"`
|
|
// Blob is an ordinary []byte, which encoding/json writes as a base64
|
|
// string. It is NOT an array of integers, and it is not a marshaler.
|
|
Blob []byte `json:"blob"`
|
|
// At is a timestamp. time.Time writes itself as an RFC 3339 string, so its
|
|
// FIELDS are not its wire shape either.
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
type relayIn struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
func relay(_ context.Context, _ *relayIn) (*relayOut, error) {
|
|
return &relayOut{Result: json.RawMessage(`[1,2]`)}, nil
|
|
}
|
|
|
|
// A type that says what it is on the wire — MarshalJSON — is not described by
|
|
// its fields, and json.RawMessage is the case that matters: it is a []byte whose
|
|
// MarshalJSON emits raw JSON, so a relay that demonstrably returns arrays and
|
|
// null was published as `{"type":"object","properties":{}}` in openapi.yaml and
|
|
// in every SDK generated from it.
|
|
//
|
|
// The rule is about the MARSHALER, so it has to be read BEFORE the []byte rule
|
|
// and before the struct rule — being a struct was never what made it true.
|
|
func TestAnyJSON_ACustomMarshalerIsNotItsFields(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "relay", DisableStartupMessage: true})
|
|
zip.Describe("GET /v1/relay/:id", zip.Doc{
|
|
Description: "Relay forwards one call.",
|
|
Fields: map[string]string{"relayOut.result": "The upstream body, untouched."},
|
|
})
|
|
zip.Get(app, "/v1/relay/:id", relay)
|
|
|
|
props := propsOf(t, app, "/v1/relay/{id}", "get")
|
|
|
|
result, _ := props["result"].(map[string]any)
|
|
if result == nil {
|
|
t.Fatalf("no `result` property: %v", props)
|
|
}
|
|
if _, typed := result["type"]; typed {
|
|
t.Errorf("result = %v, want an unconstrained schema — a RawMessage is any JSON", result)
|
|
}
|
|
// Unconstrained is not undocumented: the field's prose still lands on it.
|
|
if result["description"] != "The upstream body, untouched." {
|
|
t.Errorf("result lost its description: %v", result)
|
|
}
|
|
|
|
// A plain []byte is not a marshaler. encoding/json writes it as a base64
|
|
// string, so that is what the document says — not an array of integers.
|
|
if blob, _ := props["blob"].(map[string]any); blob["type"] != "string" {
|
|
t.Errorf("blob = %v, want a base64 string", blob)
|
|
}
|
|
|
|
// time.Time IS a marshaler, and the one whose output shape is documented:
|
|
// RFC 3339, which OpenAPI spells `format: date-time`.
|
|
at, _ := props["at"].(map[string]any)
|
|
if at["type"] != "string" || at["format"] != "date-time" {
|
|
t.Errorf("at = %v, want an RFC 3339 string", at)
|
|
}
|
|
}
|
|
|
|
// And the wire is what the schema now admits: the relay hands back an array and
|
|
// a null through the same op, unchanged.
|
|
func TestAnyJSON_RelayIsVerbatim(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "relay2", DisableStartupMessage: true})
|
|
zip.Get(app, "/v2/relay/:id", func(_ context.Context, in *relayIn) (*json.RawMessage, error) {
|
|
out := json.RawMessage(`[1,2]`)
|
|
if in.ID == "null" {
|
|
out = json.RawMessage(`null`)
|
|
}
|
|
return &out, nil
|
|
})
|
|
for id, want := range map[string]string{"x": "[1,2]", "null": "null"} {
|
|
if code, body := call2(t, app, "GET", "/v2/relay/"+id, ""); code != 200 || body != want {
|
|
t.Errorf("GET /v2/relay/%s = %d %s, want 200 %s", id, code, body, want)
|
|
}
|
|
}
|
|
|
|
// An op whose whole Out is a RawMessage says so too — the response schema is
|
|
// unconstrained, not an array of integers.
|
|
paths, _ := app.OpenAPISpec()["paths"].(map[string]map[string]any)
|
|
op, _ := paths["/v2/relay/{id}"]["get"].(map[string]any)
|
|
resp, _ := op["responses"].(map[string]any)["200"].(map[string]any)
|
|
content, _ := resp["content"].(map[string]any)["application/json"].(map[string]any)
|
|
schema, _ := content["schema"].(map[string]any)
|
|
if _, typed := schema["type"]; typed {
|
|
t.Errorf("response schema = %v, want unconstrained", schema)
|
|
}
|
|
}
|
|
|
|
// propsOf is the response schema's properties for one op, resolved through the
|
|
// $ref the document uses to name a type once.
|
|
func propsOf(t *testing.T, a *zip.App, path, method string) map[string]any {
|
|
t.Helper()
|
|
spec := a.OpenAPISpec()
|
|
paths, _ := spec["paths"].(map[string]map[string]any)
|
|
op, _ := paths[path][method].(map[string]any)
|
|
if op == nil {
|
|
t.Fatalf("no %s %s in spec", method, path)
|
|
}
|
|
resp, _ := op["responses"].(map[string]any)["200"].(map[string]any)
|
|
content, _ := resp["content"].(map[string]any)["application/json"].(map[string]any)
|
|
schema, _ := content["schema"].(map[string]any)
|
|
if ref, ok := schema["$ref"].(string); ok {
|
|
defs, _ := spec["components"].(map[string]any)["schemas"].(map[string]any)
|
|
schema, _ = defs[ref[len("#/components/schemas/"):]].(map[string]any)
|
|
}
|
|
props, _ := schema["properties"].(map[string]any)
|
|
if props == nil {
|
|
t.Fatalf("no properties in %v", schema)
|
|
}
|
|
return props
|
|
}
|