mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A manifest op can stream its answer and take its body raw
"stream": "sse" or "bytes" publishes text/event-stream or application/octet-stream instead of the 204 an op with no out projected as; "raw": true publishes an octet-stream request body. Check refuses a stream beside an out and a raw body beside an in. Claude-Session: https://claude.ai/code/session_01SCPMGSQsLNF2YtHFL4bwyp
This commit is contained in:
+24
@@ -110,6 +110,21 @@ type ManifestOp struct {
|
||||
// OAuth says the operation answers at an OAuth address, so its refusal is an
|
||||
// RFC 6749 error rather than an RFC 9457 problem document.
|
||||
OAuth bool `json:"oauth,omitempty"`
|
||||
|
||||
// Stream says the answer is written as it is produced rather than as one
|
||||
// JSON value: "sse" is a text/event-stream, "bytes" an octet stream. Empty
|
||||
// is one value, Out.
|
||||
Stream string `json:"stream,omitempty"`
|
||||
|
||||
// Raw says the request body is read as bytes, as sent, and binds to no
|
||||
// type: a relay's upload. Such an op names no In.
|
||||
Raw bool `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// streams are the answers a manifest may say an op streams, by media type.
|
||||
var streams = map[string]string{
|
||||
"sse": "text/event-stream",
|
||||
"bytes": "application/octet-stream",
|
||||
}
|
||||
|
||||
// TypeDesc is one type, described by what it is made of and by what it looks
|
||||
@@ -261,6 +276,15 @@ func (m Manifest) Check() error {
|
||||
return fmt.Errorf("op %s names %s %q, which nothing described", op.ID, what, id)
|
||||
}
|
||||
}
|
||||
if _, ok := streams[op.Stream]; op.Stream != "" && !ok {
|
||||
return fmt.Errorf("op %s streams %q; a stream is sse or bytes", op.ID, op.Stream)
|
||||
}
|
||||
if op.Stream != "" && op.Out != "" {
|
||||
return fmt.Errorf("op %s streams and names output %q; a streamed answer is not one value", op.ID, op.Out)
|
||||
}
|
||||
if op.Raw && op.In != "" {
|
||||
return fmt.Errorf("op %s reads its body raw and names input %q; a raw body binds to nothing", op.ID, op.In)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+30
-2
@@ -69,7 +69,14 @@ func ProjectOpenAPI(m Manifest) map[string]any {
|
||||
obj["tags"] = op.Tags
|
||||
}
|
||||
|
||||
if p.hasRequestBody(op) {
|
||||
if op.Raw && hasBody(op.Method) {
|
||||
obj["requestBody"] = map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/octet-stream": map[string]any{"schema": binarySchema()},
|
||||
},
|
||||
}
|
||||
} else if p.hasRequestBody(op) {
|
||||
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.In}, reg)}
|
||||
if len(op.Example) > 0 {
|
||||
media["example"] = json.RawMessage(op.Example)
|
||||
@@ -126,7 +133,22 @@ func ProjectOpenAPI(m Manifest) map[string]any {
|
||||
}
|
||||
|
||||
resp := map[string]any{}
|
||||
if out := p.types[op.Out]; out != nil && out.Name != "" {
|
||||
if kind, ok := streams[op.Stream]; ok {
|
||||
schema := binarySchema()
|
||||
if op.Stream == "sse" {
|
||||
schema = map[string]any{"type": "string"}
|
||||
}
|
||||
for _, code := range statuses(op.Statuses, 200) {
|
||||
entry := map[string]any{
|
||||
"description": statusText(code),
|
||||
"content": map[string]any{kind: map[string]any{"schema": schema}},
|
||||
}
|
||||
if h := headerDecls(op); h != nil {
|
||||
entry["headers"] = h
|
||||
}
|
||||
resp[strconv.Itoa(code)] = entry
|
||||
}
|
||||
} else if out := p.types[op.Out]; out != nil && out.Name != "" {
|
||||
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.Out}, reg)}
|
||||
if len(op.Response) > 0 {
|
||||
media["example"] = json.RawMessage(op.Response)
|
||||
@@ -177,6 +199,12 @@ func ProjectOpenAPI(m Manifest) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// binarySchema is bytes sent as they are, which is what a raw body and a byte
|
||||
// stream both are.
|
||||
func binarySchema() map[string]any {
|
||||
return map[string]any{"type": "string", "format": "binary"}
|
||||
}
|
||||
|
||||
// ProjectMCP is m as an MCP tool list, in name order.
|
||||
func ProjectMCP(m Manifest) []map[string]any {
|
||||
p := newProjector(m)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// A streamed answer is published as the stream it is. Without the manifest's
|
||||
// stream field an op with no Out projected as a 204, which is a document
|
||||
// promising an empty answer from an op that answers for minutes.
|
||||
|
||||
func streamed() zip.Manifest {
|
||||
return zip.Manifest{
|
||||
Manifest: zip.ManifestVersion,
|
||||
App: "chat",
|
||||
Ops: []zip.ManifestOp{
|
||||
{Method: "POST", Path: "/v1/chat", In: "Prompt", Stream: "sse"},
|
||||
{Method: "POST", Path: "/v1/relay", Raw: true, Stream: "bytes"},
|
||||
},
|
||||
Types: []zip.TypeDesc{{
|
||||
ID: "Prompt", Name: "Prompt", Kind: "struct",
|
||||
Fields: []zip.FieldDesc{{Name: "text", JSON: "text", Type: zip.TypeRef{Prim: "string"}}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectOpenAPIStreams(t *testing.T) {
|
||||
m := streamed()
|
||||
if err := m.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Settle()
|
||||
b, err := json.Marshal(zip.ProjectOpenAPI(m))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Paths map[string]map[string]struct {
|
||||
RequestBody struct {
|
||||
Content map[string]json.RawMessage `json:"content"`
|
||||
} `json:"requestBody"`
|
||||
Responses map[string]struct {
|
||||
Content map[string]json.RawMessage `json:"content"`
|
||||
} `json:"responses"`
|
||||
} `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat := doc.Paths["/v1/chat"]["post"]
|
||||
if _, ok := chat.Responses["200"].Content["text/event-stream"]; !ok {
|
||||
t.Errorf("chat answers %s, want a text/event-stream 200", b)
|
||||
}
|
||||
if _, ok := chat.Responses["204"]; ok {
|
||||
t.Errorf("chat is published as answering nothing")
|
||||
}
|
||||
if _, ok := chat.RequestBody.Content["application/json"]; !ok {
|
||||
t.Errorf("chat's prompt is not a JSON body")
|
||||
}
|
||||
relay := doc.Paths["/v1/relay"]["post"]
|
||||
if got := string(relay.Responses["200"].Content["application/octet-stream"]); !strings.Contains(got, `"binary"`) {
|
||||
t.Errorf("relay answers %q, want binary", got)
|
||||
}
|
||||
if got := string(relay.RequestBody.Content["application/octet-stream"]); !strings.Contains(got, `"binary"`) {
|
||||
t.Errorf("relay reads %q, want binary", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRefusesACrookedStream(t *testing.T) {
|
||||
for name, op := range map[string]zip.ManifestOp{
|
||||
"unknown stream": {Method: "GET", Path: "/a", Stream: "ws"},
|
||||
"stream and out": {Method: "GET", Path: "/a", Stream: "sse", Out: "Prompt"},
|
||||
"raw and in": {Method: "POST", Path: "/a", Raw: true, In: "Prompt"},
|
||||
} {
|
||||
m := streamed()
|
||||
m.Ops = []zip.ManifestOp{op}
|
||||
if err := m.Check(); err == nil {
|
||||
t.Errorf("%s: checked clean", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user