Files
zeekayandHanzo Dev 3e48473192 feat(http): route every listener on zap-proto/zip v1.17.2
The Data API, MCP and debug listeners each hand-rolled a net/http.ServeMux.
They now declare routes on one zip.App built by internal/util/zipapp, so ZAP
is the transport and HTTP is a view of the same routes. zipapp keeps the
net/http error rendering (plain-text 404/405, nosniff) so no client sees a
new response shape; zipapp_test asserts that parity directly.

zip.AdaptNetHTTP is the single escape hatch for the handlers that are genuinely
net/http (pprof, expvar, statsviz, promhttp) — there is no second adapter.

This work existed only locally: main's history was rewritten this morning and
took it with it, so it is replayed here against current main rather than
merged from the pre-rewrite lineage, and lands straight on the fleet-standard
v1.17.2 instead of the v1.12.0 it was written against. zip v1.17.x requires
go >= 1.26.5, hence the directive bump, and pulls zap-proto/http v0.3.1 —
peers must be on v0.3.x to talk to it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 18:01:51 -07:00

283 lines
7.5 KiB
Go

// Copyright 2021 Hanzo AI Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package server provides a Data API server handlers.
package server
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/AlekSi/lazyerrors"
"github.com/FerretDB/wire/wirebson"
"github.com/xdg-go/scram"
"github.com/zap-proto/zip"
"github.com/hanzoai/docdb/internal/clientconn/conninfo"
"github.com/hanzoai/docdb/internal/dataapi/api"
"github.com/hanzoai/docdb/internal/handler/middleware"
"github.com/hanzoai/docdb/internal/util/logging"
"github.com/hanzoai/docdb/internal/util/must"
"github.com/hanzoai/docdb/internal/util/zipapp"
)
// New creates a new Server.
func New(l *slog.Logger, handler *middleware.Middleware) *Server {
return &Server{
l: l,
m: handler,
}
}
// Server implements services described by OpenAPI description file.
type Server struct {
l *slog.Logger
m *middleware.Middleware
}
// Auth handles SCRAM authentication based on the username and password specified in request.
// After a successful handshake it calls the next handler.
func (s *Server) Auth(c *zip.Ctx) error {
ctx := c.Context()
username, password, ok := basicAuth(c)
if !ok {
return writeError(c, errorNoAuthenticationSpecified, http.StatusBadRequest)
}
if password == "" || username == "" {
return writeError(c, errorMissingAuthenticationParameter, http.StatusBadRequest)
}
client, err := scram.SHA256.NewClient(username, password, "")
if err != nil {
return zipapp.Text(c, http.StatusBadRequest, lazyerrors.Error(err).Error())
}
conv := client.NewConversation()
payload, err := conv.Step("")
if err != nil {
return zipapp.Text(c, http.StatusBadRequest, lazyerrors.Error(err).Error())
}
msg := must.NotFail(prepareRequest(
"saslStart", int32(1),
"mechanism", "SCRAM-SHA-256",
"payload", wirebson.Binary{B: []byte(payload)},
// use skipEmptyExchange to complete the handshake with one `saslStart` and one `saslContinue`
"options", wirebson.MustDocument("skipEmptyExchange", true),
"$db", "admin",
))
resp := s.m.Handle(ctx, msg)
if resp == nil {
return zipapp.Text(c, http.StatusInternalServerError, "internal error")
}
if !resp.OK() {
return s.writeJSONError(c, resp)
}
convID := resp.Document().Get("conversationId").(int32)
payloadBytes := resp.Document().Get("payload").(wirebson.Binary).B
payload, err = conv.Step(string(payloadBytes))
if err != nil {
return zipapp.Text(c, http.StatusUnauthorized, lazyerrors.Error(err).Error())
}
msg = must.NotFail(prepareRequest(
"saslContinue", int32(1),
"conversationId", convID,
"payload", wirebson.Binary{B: []byte(payload)},
"$db", "admin",
))
resp = s.m.Handle(ctx, msg)
if resp == nil {
return zipapp.Text(c, http.StatusInternalServerError, "internal error")
}
if !resp.OK() {
return s.writeJSONError(c, resp)
}
if !resp.Document().Get("done").(bool) {
return zipapp.Text(c, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
}
payloadBytes = resp.Document().Get("payload").(wirebson.Binary).B
if _, err = conv.Step(string(payloadBytes)); err != nil {
return zipapp.Text(c, http.StatusUnauthorized, lazyerrors.Error(err).Error())
}
if !conv.Valid() {
return zipapp.Text(c, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
}
return c.Next()
}
// basicAuth returns the username and password from the request's HTTP Basic
// Authentication header, exactly as [http.Request.BasicAuth] parses it.
func basicAuth(c *zip.Ctx) (username, password string, ok bool) {
r := http.Request{Header: http.Header{"Authorization": []string{c.Header("Authorization")}}}
return r.BasicAuth()
}
// ConnInfo creates a new [*conninfo.ConnInfo], calls the next handler,
// and closes the connection info after the request is done.
func (s *Server) ConnInfo(c *zip.Ctx) error {
ci := conninfo.New()
defer ci.Close()
c.SetContext(conninfo.Ctx(c.Context(), ci))
return c.Next()
}
// writeJSONResponse marshals provided res document into extended JSON and writes it as the response body.
func (s *Server) writeJSONResponse(c *zip.Ctx, res api.Response) error {
ctx := c.Context()
c.SetHeader("Content-Type", "application/json")
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(res); err != nil {
s.l.ErrorContext(ctx, "marshalJSON failed", logging.Error(err))
}
if s.l.Enabled(ctx, slog.LevelDebug) {
// extended JSON value writer always finish with '\n' character
s.l.DebugContext(ctx, fmt.Sprintf("Results:\n%s", strings.TrimSpace(buf.String())))
}
return c.Fiber().Send(buf.Bytes())
}
// TODO https://github.com/hanzoai/docdb/issues/4965
func (s *Server) writeJSONError(c *zip.Ctx, resp *middleware.Response) error {
doc := resp.Document()
errmsg := doc.Get("errmsg").(string)
codeName := doc.Get("codeName").(string)
c.Status(http.StatusInternalServerError)
return s.writeJSONResponse(c, &api.Error{
Error: errmsg,
ErrorCode: codeName,
})
}
// prepareDocument creates a new bson document from the given pairs of
// field names and values, which can be used as handler command msg.
//
// If any of pair values is nil it's ignored.
func prepareDocument(pairs ...any) (*wirebson.Document, error) {
l := len(pairs)
if l%2 != 0 {
return nil, lazyerrors.Errorf("invalid number of arguments: %d", l)
}
docPairs := make([]any, 0, l)
for i := 0; i < l; i += 2 {
var err error
key := pairs[i]
v := pairs[i+1]
switch val := v.(type) {
// json.RawMessage is the non-pointer exception.
// Other non-pointer types don't need special handling.
case json.RawMessage:
v, err = unmarshalSingleJSON(&val)
if err != nil {
return nil, err
}
case *json.RawMessage:
if val == nil {
continue
}
v, err = unmarshalSingleJSON(val)
if err != nil {
return nil, err
}
case *float32:
if val == nil {
continue
}
v = float64(*val)
case *bool:
if val == nil {
continue
}
v = *val
}
if v == nil {
continue
}
docPairs = append(docPairs, key, v)
}
return wirebson.NewDocument(docPairs...)
}
// prepareRequest creates a new middleware request from the given pairs of field names and values,
// which can be used as handler command msg.
//
// If any of pair values is nil it's ignored.
func prepareRequest(pairs ...any) (*middleware.Request, error) {
doc, err := prepareDocument(pairs...)
if err != nil {
return nil, lazyerrors.Error(err)
}
return middleware.RequestDoc(doc)
}
// decodeJSONRequest logs the request when debug logging is enabled, then decodes
// its JSON body into the provided oapi generated request struct.
func (s *Server) decodeJSONRequest(c *zip.Ctx, out any) error {
if s.l.Enabled(c.Context(), slog.LevelDebug) {
s.l.DebugContext(c.Context(), fmt.Sprintf("Request:\n%s", c.Fiber().Request().String()))
}
if !strings.HasPrefix(c.Header("Content-Type"), "application/json") {
return lazyerrors.New("Content-Type must be set to application/json")
}
if err := json.NewDecoder(bytes.NewReader(c.Body())).Decode(&out); err != nil {
return lazyerrors.Error(err)
}
return nil
}