feat: release managed service generation and contract diagnostics

This commit is contained in:
2026-09-24 17:34:21 +08:00
parent eb69260586
commit 158b8002b3
85 changed files with 5722 additions and 360 deletions

173
httpx/multipart.go Normal file
View File

@@ -0,0 +1,173 @@
package httpx
import (
"bytes"
"errors"
"io"
"mime/multipart"
"net/http"
"net/textproto"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// MultipartReader streams request parts and retains parsing failures, so a
// partially consumed or invalid upload cannot later complete successfully.
// It belongs to a single request and must be read sequentially.
type MultipartReader struct {
reader *multipart.Reader
input *multipartInput
current *MultipartPart
err error
done bool
}
// NextPart closes the preceding part and returns the next part. Like the
// standard library, it decodes quoted-printable content; NextRawPart does not.
// Only a valid final boundary yields io.EOF; malformed input yields an error
// with status 400. Body limits remain detectable as *http.MaxBytesError.
func (r *MultipartReader) NextPart() (*MultipartPart, error) { return r.next(false) }
func (r *MultipartReader) NextRawPart() (*MultipartPart, error) { return r.next(true) }
func (r *MultipartReader) next(raw bool) (*MultipartPart, error) {
if r.err != nil {
return nil, r.err
}
if r.done {
return nil, io.EOF
}
if r.current != nil {
if err := r.current.Close(); err != nil {
return nil, err
}
}
var part *multipart.Part
var err error
if raw {
part, err = r.reader.NextRawPart()
} else {
part, err = r.reader.NextPart()
}
if err == io.EOF {
// The standard parser also returns bare EOF for truncated MIME
// headers. Require an actual closing delimiter before treating EOF
// as successful completion.
if !r.input.ended() {
return nil, r.record(io.ErrUnexpectedEOF)
}
r.done = true
return nil, io.EOF
}
if err != nil {
return nil, r.record(err)
}
r.current = &MultipartPart{Header: part.Header, part: part, owner: r}
return r.current, nil
}
func (r *MultipartReader) record(err error) error {
if err == nil || err == io.EOF {
return err
}
if r.err == nil {
var limit *http.MaxBytesError
if errors.As(err, &limit) {
r.err = err
} else {
r.err = apierrors.New(400, "INVALID_MULTIPART", "invalid multipart request body").WithCause(err)
}
}
return r.err
}
// MultipartPart is a streaming request part. Close drains unread content and
// reports read/parse errors; it never buffers the whole file or creates a file.
type MultipartPart struct {
Header textproto.MIMEHeader
part *multipart.Part
owner *MultipartReader
closed bool
err error
}
func (p *MultipartPart) FormName() string { return p.part.FormName() }
func (p *MultipartPart) FileName() string { return p.part.FileName() }
func (p *MultipartPart) Read(buf []byte) (int, error) {
if p.closed {
if p.err != nil {
return 0, p.err
}
return 0, io.EOF
}
n, err := p.part.Read(buf)
return n, p.owner.record(err)
}
func (p *MultipartPart) Close() error {
if !p.closed {
_, p.err = io.Copy(io.Discard, p)
p.closed = true
}
return p.err
}
// multipartInput observes closing-delimiter lines without buffering file
// content or changing bytes. The MIME parser still validates their syntax and
// placement. Keep only a prefix offset, including across reader boundaries.
type multipartInput struct {
io.Reader
boundary string
matched int
carriage, final, eof bool
}
func (r *multipartInput) Read(p []byte) (int, error) {
n, err := r.Reader.Read(p)
r.observe(p[:n])
if err == io.EOF {
r.eof = true
}
return n, err
}
func (r *multipartInput) ended() bool {
return r.final || (r.eof && r.matched == len(r.boundary) && !r.carriage)
}
func (r *multipartInput) observe(data []byte) {
for len(data) > 0 && !r.final {
if r.matched < 0 {
newline := bytes.IndexByte(data, '\n')
if newline < 0 {
return
}
data, r.matched, r.carriage = data[newline+1:], 0, false
continue
}
c := data[0]
data = data[1:]
if r.matched < len(r.boundary) {
if c == r.boundary[r.matched] {
r.matched++
continue
}
} else {
switch {
case c == '\n':
r.final = true
continue
case !r.carriage && (c == ' ' || c == '\t'):
continue
case !r.carriage && c == '\r':
r.carriage = true
continue
}
}
r.matched, r.carriage = -1, false
if c == '\n' {
r.matched = 0
}
}
}

128
httpx/transfer.go Normal file
View File

@@ -0,0 +1,128 @@
package httpx
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// Body borrows the limited request stream for the duration of a Logic call.
// Raw contains the exact body bytes (never JSON-decoded). For multipart input,
// consume Parts sequentially with NextPart and close each part. No temporary
// files are created. Do not retain Body or its readers after Logic returns.
type Body struct {
Raw io.Reader
Parts *MultipartReader
Header http.Header
}
func NewBody(r *http.Request, mode string) (*Body, error) {
if r == nil {
return nil, errors.New("httpx: request required")
}
b := &Body{Raw: r.Body, Header: r.Header.Clone()}
if b.Raw == nil {
b.Raw = http.NoBody
}
switch mode {
case "raw":
case "multipart":
media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "multipart/form-data" || params["boundary"] == "" {
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
}
input := &multipartInput{Reader: b.Raw, boundary: "--" + params["boundary"] + "--"}
request := *r
request.Body = io.NopCloser(input)
parts, err := request.MultipartReader()
if err != nil {
return nil, apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "multipart/form-data with a valid boundary required").WithCause(err)
}
b.Parts = &MultipartReader{reader: parts, input: input}
default:
return nil, fmt.Errorf("httpx: unsupported body mode %q", mode)
}
return b, nil
}
// Complete validates remaining multipart parts and checks unread bytes against
// the route limit before committing a success response. Slow clients remain
// bounded by the server's read timeout. Do not read Raw alongside Parts.
func (b *Body) Complete() error {
if b == nil {
return nil
}
if b.Parts != nil {
for {
part, err := b.Parts.NextPart()
if err == io.EOF {
break
}
if err != nil {
return err
}
if err = part.Close(); err != nil {
return err
}
}
}
if b.Raw == nil {
return nil
}
_, err := io.Copy(io.Discard, b.Raw)
return err
}
// Stream transfers ownership of Content to the generated handler. The handler
// closes it even on failure, and writes it without the JSON response envelope.
// Name enables an attachment filename; leave it empty for raw callback output.
// ContentType may refine the declared type with parameters (such as charset).
type Stream struct {
Content io.ReadCloser
Name string
ContentType string
}
func (s *Stream) Close() error {
if s == nil || s.Content == nil {
return nil
}
return s.Content.Close()
}
func (c *Context) WriteStream(status int, declaredType string, stream *Stream) (err error) {
if stream == nil {
return errors.New("httpx: nil stream response")
}
defer func() { err = errors.Join(err, stream.Close()) }()
if stream.Content == nil {
return errors.New("httpx: stream content required")
}
if c.Responded() {
return errors.New("httpx: response already committed")
}
if status < 200 || status > 599 || status == 204 || status == 205 || status == 304 {
return errors.New("httpx: stream requires body-bearing status")
}
contentType := stream.ContentType
if contentType == "" {
contentType = declaredType
}
media, _, parseErr := mime.ParseMediaType(contentType)
if parseErr != nil || media != declaredType || strings.ContainsAny(contentType, "\r\n") {
return errors.New("httpx: stream content type differs from API contract")
}
writer := c.Raw()
writer.Header().Set("Content-Type", contentType)
if stream.Name != "" {
writer.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": stream.Name}))
}
writer.WriteHeader(status)
_, err = io.Copy(writer, stream.Content)
return err
}

271
httpx/transfer_test.go Normal file
View File

@@ -0,0 +1,271 @@
package httpx_test
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/iotest"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type testStream struct {
io.Reader
closed int
closeErr error
}
func (s *testStream) Close() error { s.closed++; return s.closeErr }
type brokenReader struct{ read bool }
func (r *brokenReader) Read(p []byte) (int, error) {
if r.read {
return 0, errors.New("source failed")
}
r.read = true
return copy(p, "partial"), nil
}
func TestStreamOwnershipAndCommit(t *testing.T) {
for _, tc := range []struct {
name, media string
reader io.Reader
wantStatus int
wantBody string
closeErr error
}{
{name: "download", media: "application/octet-stream", reader: strings.NewReader("bytes"), wantStatus: 200, wantBody: "bytes"},
{name: "invalidType", media: "text/html", reader: strings.NewReader("secret"), wantStatus: 500},
{name: "readError", media: "application/octet-stream", reader: &brokenReader{}, wantStatus: 200, wantBody: "partial"},
{name: "closeError", media: "application/octet-stream", reader: strings.NewReader("done"), wantStatus: 200, wantBody: "done", closeErr: errors.New("close failed")},
} {
t.Run(tc.name, func(t *testing.T) {
stream := &testStream{Reader: tc.reader, closeErr: tc.closeErr}
app := omni.New()
app.GET("/file", func(c *httpx.Context) error {
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: stream, ContentType: tc.media, Name: "文件.txt"})
})
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/file", nil))
if stream.closed != 1 || out.Code != tc.wantStatus {
t.Fatalf("closed=%d status=%d body=%s", stream.closed, out.Code, out.Body.String())
}
if tc.wantBody != "" && out.Body.String() != tc.wantBody {
t.Fatal("error JSON appended to stream", out.Body.String())
}
if tc.wantStatus == 500 && strings.Contains(out.Body.String(), "secret") {
t.Fatal("stream written before validation")
}
})
}
}
func TestBodyCompletionEnforcesUnreadLimit(t *testing.T) {
app := omni.New()
app.HandleWithBodyLimit("POST", "/raw", 4, nil, func(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "raw")
if err != nil {
return err
}
buf := make([]byte, 1)
if _, err = io.ReadFull(body.Raw, buf); err != nil {
return err
}
if err = body.Complete(); err != nil {
return err
}
return c.OK("accepted")
})
req := httptest.NewRequest("POST", "/raw", strings.NewReader("12345"))
req.ContentLength = -1
out := httptest.NewRecorder()
app.ServeHTTP(out, req)
if out.Code != http.StatusRequestEntityTooLarge {
t.Fatal(out.Code, out.Body.String())
}
}
func TestRawBodyPreservesBytesAndHeaders(t *testing.T) {
req := httptest.NewRequest("POST", "/", strings.NewReader(" {\n \"a\":1} "))
req.Header.Set("X-Signature", "original")
body, err := httpx.NewBody(req, "raw")
if err != nil {
t.Fatal(err)
}
body.Header.Set("X-Signature", "changed")
if req.Header.Get("X-Signature") != "original" {
t.Fatal("aliased headers")
}
data, err := io.ReadAll(body.Raw)
if err != nil || string(data) != " {\n \"a\":1} " {
t.Fatal(string(data), err)
}
}
func TestMultipartValidationBeforeSuccess(t *testing.T) {
const prefix = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"x.txt\"\r\n\r\n"
for _, tc := range []struct {
name, wire, action string
status int
limit int64
}{
{"unreadValid", prefix + "hello\r\n--boundary--\r\n", "skip", 200, 1024},
{"finalWithoutNewline", prefix + "hello\r\n--boundary--", "skip", 200, 1024},
{"finalWithWhitespace", prefix + "hello\r\n--boundary-- \t", "skip", 200, 1024},
{"epilogue", prefix + "hello\r\n--boundary--\r\nignored epilogue", "skip", 200, 1024},
{"epilogueOverLimit", prefix + "hello\r\n--boundary--\r\n" + strings.Repeat("x", 1024), "skip", 413, 1024},
{"unreadTruncated", prefix + "private file bytes", "skip", 400, 1024},
{"empty", "", "skip", 400, 1024},
{"emptyMultipart", "--boundary--\r\n", "skip", 200, 1024},
{"unterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "read", 400, 1024},
{"skippedUnterminatedHeader", "--boundary\r\nContent-Disposition: form-data", "skip", 400, 1024},
{"boundaryInsideHeader", "--boundary\r\nX-Header: x\r\n--boundary--", "skip", 400, 1024},
{"readTruncated", prefix + "private file bytes", "read", 400, 1024},
{"closeTruncated", prefix + "private file bytes", "close", 400, 1024},
{"swallowedReadError", prefix + "private file bytes", "ignore", 400, 1024},
{"badHeader", "--boundary\r\nbroken header\r\n\r\nprivate file bytes", "read", 400, 1024},
{"overLimit", prefix + strings.Repeat("x", 150), "read", 413, 128},
{"unreadOverLimit", prefix + strings.Repeat("x", 150), "skip", 413, 128},
{"businessFailure", prefix + "hello\r\n--boundary--\r\n", "business", 500, 1024},
} {
t.Run(tc.name, func(t *testing.T) {
app := omni.New()
app.HandleWithBodyLimit("POST", "/upload", tc.limit, nil, func(c *httpx.Context) error {
body, err := httpx.NewBody(c.Request(), "multipart")
if err != nil {
return err
}
if tc.action == "business" {
return errors.New("private database error")
}
if tc.action != "skip" {
part, err := body.Parts.NextPart()
if err != nil {
return err
}
if tc.action == "close" {
if err = part.Close(); err != nil {
return err
}
} else {
_, err = io.Copy(io.Discard, part)
if err != nil && tc.action != "ignore" {
return err
}
}
}
if err = body.Complete(); err != nil {
return err
}
return c.OK("accepted")
})
req := httptest.NewRequest("POST", "/upload", strings.NewReader(tc.wire))
req.ContentLength = -1
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
out := httptest.NewRecorder()
app.ServeHTTP(out, req)
if out.Code != tc.status {
t.Fatalf("want %d, got %d: %s", tc.status, out.Code, out.Body.String())
}
if strings.Contains(out.Body.String(), "private") {
t.Fatal("internal contents leaked", out.Body.String())
}
})
}
}
func TestMultipartIterationAndRawParts(t *testing.T) {
const wire = "--b\r\nContent-Disposition: form-data; name=\"first\"; filename=\"x.txt\"\r\n\r\nskipped\r\n--b\r\nContent-Disposition: form-data; name=\"second\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\na=3Db\r\n--b--\r\n"
for _, raw := range []bool{false, true} {
req := httptest.NewRequest("POST", "/", strings.NewReader(wire))
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
body, err := httpx.NewBody(req, "multipart")
if err != nil {
t.Fatal(err)
}
first, err := body.Parts.NextPart()
if err != nil {
t.Fatal(err)
}
if first.FormName() != "first" || first.FileName() != "x.txt" {
t.Fatal("part metadata lost")
}
// NextPart must drain/close the unread first part without treating a
// valid part's EOF as the end of the entire request.
var second *httpx.MultipartPart
want := "a=b"
if raw {
second, err = body.Parts.NextRawPart()
want = "a=3Db"
} else {
second, err = body.Parts.NextPart()
}
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(second)
if err != nil || string(data) != want || second.FormName() != "second" {
t.Fatal(string(data), err)
}
if err = body.Complete(); err != nil {
t.Fatal(err)
}
if err = body.Complete(); err != nil {
t.Fatal("second Complete", err)
}
if err = first.Close(); err != nil {
t.Fatal("second Close", err)
}
if _, err = body.Parts.NextPart(); err != io.EOF {
t.Fatal("expected final EOF", err)
}
if _, err = first.Read(make([]byte, 1)); err != io.EOF {
t.Fatal("closed part remained readable", err)
}
}
}
func TestMultipartRequiresFormData(t *testing.T) {
for _, media := range []string{"multipart/mixed; boundary=b", "multipart/form-data", "multipart/form-data; boundary=\"\"", "application/json"} {
req := httptest.NewRequest("POST", "/", strings.NewReader("--b--\r\n"))
req.Header.Set("Content-Type", media)
_, err := httpx.NewBody(req, "multipart")
var public *apierrors.APIError
if !errors.As(err, &public) || public.Status != 415 {
t.Fatalf("media %q accepted: %v", media, err)
}
}
}
func TestMultipartBoundariesAcrossReads(t *testing.T) {
for _, tc := range []struct {
name, wire string
valid bool
}{
{"crlf", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--\r\n", true},
{"lf", "--b\nContent-Disposition: form-data; name=x\n\nabc\n--b--\n", true},
{"noNewline", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b--", true},
{"headerEOF", "--b\r\nContent-Disposition: form-data; name=x", false},
{"secondHeaderEOF", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b\r\nContent-Disposition: form-data; name=y", false},
{"partialBoundary", "--b\r\nContent-Disposition: form-data; name=x\r\n\r\nabc\r\n--b-", false},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/", iotest.OneByteReader(strings.NewReader(tc.wire)))
req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
body, err := httpx.NewBody(req, "multipart")
if err != nil {
t.Fatal(err)
}
err = body.Complete()
if tc.valid && err != nil {
t.Fatal(err)
}
if !tc.valid && (err == nil || apierrors.From(err).Status != 400) {
t.Fatal("expected malformed upload", err)
}
})
}
}