feat: release configurable API response contracts

Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification.

Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
This commit is contained in:
2026-09-23 20:42:38 +08:00
parent bef7ea2641
commit 7670b222ac
47 changed files with 2689 additions and 120 deletions

View File

@@ -30,6 +30,12 @@ type Router interface {
Group(path string) RouterGroup
}
// FallbackRouter optionally routes 404/405 through the application's response
// policy and middleware. Custom adapters can implement it without changing Router.
type FallbackRouter interface {
SetFallbacks(notFound, methodNotAllowed http.Handler)
}
// RouterGroup is the route-registration portion of Router.
type RouterGroup interface {
Handle(method, path string, handler http.Handler)
@@ -87,6 +93,7 @@ type Context struct {
values map[string]any
responded bool
status int
encoder response.Encoder
}
// NewContext creates a transport-neutral GoOmni context.
@@ -121,6 +128,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer }
// Stored values themselves are shared; middleware must not mutate them concurrently.
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
clone := NewContext(c.request.Clone(ctx), writer, c.params)
clone.encoder = c.encoder
for key, value := range c.values {
clone.values[key] = value
}
@@ -242,7 +250,8 @@ func (c *Context) NoContent() error {
}
func (c *Context) respond(status int, code, message string, data any) error {
return c.JSON(status, response.Response{
return c.writeResult(response.Result{
Status: status,
Code: code,
Message: message,
Data: data,
@@ -265,13 +274,18 @@ func (c *Context) Responded() bool { return c.responded }
// serialized. Responses committed through Context methods are preserved;
// direct Raw writes are outside this response-tracking contract.
func (c *Context) WriteError(err error) {
if c.responded {
if c.responded || err == nil {
return
}
apiErr := apierrors.From(err)
_ = c.JSON(apiErr.Status, response.Response{
if writeErr := c.writeResult(response.Result{
Status: apiErr.Status,
Code: apiErr.Code,
Message: apiErr.Message,
RequestID: c.RequestID(),
})
Err: err,
}); writeErr != nil && !c.responded {
// An invalid custom encoder must not recurse or leak a different envelope.
_ = c.WriteResponse(http.StatusInternalServerError, nil, nil)
}
}

85
httpx/response.go Normal file
View File

@@ -0,0 +1,85 @@
package httpx
import (
"encoding/xml"
"fmt"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
// SetResponseEncoder overrides this request's success AND error encoding. A
// cloned context inherits it, including buffered/idempotent requests.
func (c *Context) SetResponseEncoder(encoder response.Encoder) { c.encoder = encoder }
// WithResponse applies a policy to one route/group. It deliberately stays set
// until the outer error boundary finishes handling this request.
func WithResponse(encoder response.Encoder) Middleware {
return func(next Handler) Handler {
return func(c *Context) error { c.SetResponseEncoder(encoder); return next(c) }
}
}
// ResponseRegistrar installs the response policy before application middleware,
// so early auth/protection failures and buffered clones use the route contract.
type ResponseRegistrar interface {
HandleWithResponse(string, string, response.Encoder, Handler, ...Middleware)
}
// HandleWithResponse uses the registrar's early policy hook when available.
// Custom registrars without this hook get an ordinary leading route middleware.
func HandleWithResponse(router interface {
Handle(string, string, Handler, ...Middleware)
}, method, path string, encoder response.Encoder, handler Handler, middleware ...Middleware) {
if r, ok := router.(ResponseRegistrar); ok {
r.HandleWithResponse(method, path, encoder, handler, middleware...)
return
}
all := append([]Middleware{WithResponse(encoder)}, middleware...)
router.Handle(method, path, handler, all...)
}
func (c *Context) writeResult(result response.Result) (err error) {
if c.responded {
return nil
}
defer func() {
if recovered := recover(); recovered != nil {
err = apierrors.Internal(fmt.Errorf("response encoder panic: %v", recovered))
}
}()
encoder := c.encoder
if encoder == nil {
encoder = response.Default()
}
payload, err := encoder.Encode(result)
if err != nil {
return apierrors.Internal(err)
}
if payload.Status < 200 || payload.Status > 599 {
return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status))
}
if (payload.Status == 204 || payload.Status == 304) && len(payload.Body) > 0 {
return apierrors.Internal(fmt.Errorf("response encoder returned a body for status %d", payload.Status))
}
return c.WriteResponse(payload.Status, payload.Header, payload.Body)
}
// Bytes, Text and XML are explicit wire responses; they bypass any envelope.
func (c *Context) Bytes(status int, contentType string, body []byte) error {
return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body)
}
func (c *Context) Text(status int, body string) error {
return c.Bytes(status, "text/plain; charset=utf-8", []byte(body))
}
func (c *Context) XML(status int, value any) error {
if c.responded {
return nil
}
body, err := xml.Marshal(value)
if err != nil {
return apierrors.Internal(err)
}
return c.Bytes(status, "application/xml; charset=utf-8", body)
}