mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A typed op could only take one JSON value and answer one, so uploads, signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE, WebSockets, OAuth redirects and upstream relays stayed untyped routes with no schema, tool, command or SDK method. Each is now a typed op and its REST wire is unchanged. Requests: a zip.Body field takes the body and Content-Type exactly as sent; an In implementing Parser decodes itself; form: fields and *zip.File parts bind url-encoded and multipart forms; cookie: binds a request cookie as an in: cookie parameter. Consumes names the media. Answers: zip.Body (bytes, filename, its own status and headers, a Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T] (events with keep-alive), Socket[M] (an upgrade after the contract runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces, and unions: a type whose OneOf method's results are the alternatives, with Or[A,B] as the stock one filing each under the status it states. Projections follow from the manifest: the document publishes binary media, form schemas, x-events, x-socket with 101, 3xx with Location and oneOf; MCP takes bytes as base64 and returns bytes, text or collected events as tool content, listing a socket op in _meta as no tool; the CLI reads --body @file|-, --field name=@file and --out, prints events as they arrive and bridges a socket to stdin and stdout. tools/list is ProjectMCP over the manifest, the projection MCPTools already was. Prose reaches promoted fields (filed under the declaring struct), a type defined over another struct, and the type arguments of zip's kinds and OneOf results; an instantiated generic is published under its name followed by its argument names instead of an illegal component key. The unused reflect-only document and tool builders go.
162 lines
4.6 KiB
Go
162 lines
4.6 KiB
Go
// 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fasthttp/websocket"
|
|
|
|
"github.com/zap-proto/zip"
|
|
"github.com/zap-proto/zip/wsx"
|
|
)
|
|
|
|
// An op whose answer is a WebSocket validates, authorizes and binds its input
|
|
// like any other, and then the connection is upgraded and served. Its document
|
|
// shows the upgrade and the type of one message; it is no MCP tool, and
|
|
// tools/list says why.
|
|
|
|
type roomIn struct {
|
|
Room string `json:"room" url:"room" validate:"required"`
|
|
}
|
|
|
|
type line struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
func room(_ context.Context, in *roomIn) (*zip.Socket[line], error) {
|
|
return &zip.Socket[line]{Serve: func(conn *wsx.Conn) error {
|
|
for {
|
|
_, msg, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(in.Room+":"+strings.ToUpper(string(msg)))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}}, nil
|
|
}
|
|
|
|
func socketApp() *zip.App {
|
|
app := zip.New(zip.Config{AppName: "chat", DisableStartupMessage: true})
|
|
app.Get("/v1/rooms", room)
|
|
return app
|
|
}
|
|
|
|
// serveHTTP starts app on a fresh port and answers its host:port once it
|
|
// accepts connections.
|
|
func serveHTTP(t *testing.T, app *zip.App) string {
|
|
t.Helper()
|
|
addr := freeAddr(t)
|
|
go func() { _ = app.Listen("http://" + addr) }()
|
|
t.Cleanup(func() { _ = app.Shutdown() })
|
|
for i := 0; i < 100; i++ {
|
|
if c, err := net.Dial("tcp", addr); err == nil {
|
|
_ = c.Close()
|
|
return addr
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s never accepted", addr)
|
|
return ""
|
|
}
|
|
|
|
func TestSocket_UpgradesAfterTheContract(t *testing.T) {
|
|
addr := serveHTTP(t, socketApp())
|
|
d := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
|
|
|
conn, resp, err := d.Dial("ws://"+addr+"/v1/rooms?room=r1", nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
if resp.StatusCode != http.StatusSwitchingProtocols {
|
|
t.Errorf("handshake = %d", resp.StatusCode)
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte("hi")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, got, err := conn.ReadMessage(); err != nil || string(got) != "r1:HI" {
|
|
t.Fatalf("got %q, %v", got, err)
|
|
}
|
|
|
|
// The input is validated before anything upgrades.
|
|
_, resp, err = d.Dial("ws://"+addr+"/v1/rooms", nil)
|
|
if err == nil || resp == nil || resp.StatusCode != 400 {
|
|
t.Errorf("an invalid request upgraded: %v %v", resp, err)
|
|
}
|
|
}
|
|
|
|
// A request that does not ask to upgrade is told how, as wsx tells it.
|
|
func TestSocket_APlainRequestIsTold426(t *testing.T) {
|
|
resp, err := socketApp().Test(httptest.NewRequest("GET", "/v1/rooms?room=r1", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != 426 || resp.Header.Get("Upgrade") != "websocket" || !strings.Contains(string(body), "WebSocket") {
|
|
t.Errorf("%d %q %s", resp.StatusCode, resp.Header.Get("Upgrade"), body)
|
|
}
|
|
}
|
|
|
|
func TestSocket_IsPublishedAsAnUpgrade(t *testing.T) {
|
|
app := socketApp()
|
|
op := opIn(t, app, "/v1/rooms", "get")
|
|
if op["x-socket"] != "websocket" {
|
|
t.Errorf("x-socket = %v", op["x-socket"])
|
|
}
|
|
up, ok := op["responses"].(map[string]any)["101"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("responses = %v, want 101", op["responses"])
|
|
}
|
|
if ref, _ := up["x-events"].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "/line") {
|
|
t.Errorf("101 = %v, want the message type", up)
|
|
}
|
|
|
|
built(t, app)
|
|
for _, tool := range app.MCPTools() {
|
|
t.Errorf("a socket is a tool: %v", tool["name"])
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
|
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var env struct {
|
|
Result struct {
|
|
Meta struct {
|
|
Refused map[string]string `json:"refused"`
|
|
} `json:"_meta"`
|
|
} `json:"result"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&env)
|
|
if got := env.Result.Meta.Refused[zip.ID("GET", "/v1/rooms")]; got != zip.NotACall {
|
|
t.Errorf("tools/list _meta.refused = %v", env.Result.Meta.Refused)
|
|
}
|
|
}
|
|
|
|
// A socket op may only be a GET, and its upgrade is the whole answer.
|
|
func TestSocket_RefusesWhatCannotUpgrade(t *testing.T) {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("a POST that upgrades registered")
|
|
}
|
|
}()
|
|
zip.New(zip.Config{DisableStartupMessage: true}).Post("/v1/rooms", room)
|
|
}
|