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.
292 lines
8.4 KiB
Go
292 lines
8.4 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
|
"git.yuntuo.hk/k2safe/go-omni/validate"
|
|
)
|
|
|
|
// Handler is the function signature used by GoOmni routes.
|
|
type Handler func(*Context) error
|
|
|
|
// Middleware wraps a Handler and may enforce cross-cutting behavior.
|
|
type Middleware func(Handler) Handler
|
|
|
|
// Router is the small contract the application needs from a network layer.
|
|
// The default implementation is backed by the standard library, but another
|
|
// router or a future GoOmni transport can implement this interface without
|
|
// changing business handlers. Register routes before serving requests. Implementations
|
|
// should pass transport/transporttest.Run; see transport/README.md for the
|
|
// portable path, method, fallback and context-propagation rules.
|
|
type Router interface {
|
|
http.Handler
|
|
Handle(method, path string, handler http.Handler)
|
|
Any(path string, handler http.Handler)
|
|
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)
|
|
Any(path string, handler http.Handler)
|
|
Group(path string) RouterGroup
|
|
}
|
|
|
|
type requestContextKey uint8
|
|
|
|
const (
|
|
routeParamsKey requestContextKey = iota + 1
|
|
routePatternKey
|
|
)
|
|
|
|
// WithRouteParams lets a router adapter expose path parameters to the
|
|
// transport-neutral Context.
|
|
func WithRouteParams(ctx context.Context, params map[string]string) context.Context {
|
|
copyOfParams := make(map[string]string, len(params))
|
|
for key, value := range params {
|
|
copyOfParams[key] = value
|
|
}
|
|
return context.WithValue(ctx, routeParamsKey, copyOfParams)
|
|
}
|
|
|
|
// RouteParams returns path parameters installed by a router adapter.
|
|
func RouteParams(ctx context.Context) map[string]string {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
params, _ := ctx.Value(routeParamsKey).(map[string]string)
|
|
return params
|
|
}
|
|
|
|
// WithRoutePattern records the router's matched route pattern for access logs.
|
|
func WithRoutePattern(ctx context.Context, pattern string) context.Context {
|
|
return context.WithValue(ctx, routePatternKey, pattern)
|
|
}
|
|
|
|
// RoutePattern returns the matched route pattern, when the adapter provides it.
|
|
func RoutePattern(ctx context.Context) string {
|
|
if ctx == nil {
|
|
return ""
|
|
}
|
|
pattern, _ := ctx.Value(routePatternKey).(string)
|
|
return pattern
|
|
}
|
|
|
|
// Context provides the business-facing request and response API. It contains
|
|
// only standard-library HTTP types, so it remains usable when the network
|
|
// implementation is replaced.
|
|
type Context struct {
|
|
request *http.Request
|
|
writer http.ResponseWriter
|
|
params map[string]string
|
|
values map[string]any
|
|
responded bool
|
|
status int
|
|
encoder response.Encoder
|
|
}
|
|
|
|
// NewContext creates a transport-neutral GoOmni context.
|
|
func NewContext(request *http.Request, writer http.ResponseWriter, params map[string]string) *Context {
|
|
if request == nil {
|
|
request = &http.Request{}
|
|
}
|
|
return &Context{
|
|
request: request,
|
|
writer: writer,
|
|
params: params,
|
|
values: make(map[string]any),
|
|
}
|
|
}
|
|
|
|
func (c *Context) Request() *http.Request { return c.request }
|
|
|
|
// Context returns the standard request context. It carries GoOmni
|
|
// correlation values such as the request ID and can be passed to services.
|
|
func (c *Context) Context() context.Context {
|
|
if c.request == nil {
|
|
return context.Background()
|
|
}
|
|
return c.request.Context()
|
|
}
|
|
|
|
// Raw returns the response writer supplied by the active network adapter.
|
|
// Prefer the transport-neutral methods on Context for portable handlers.
|
|
func (c *Context) Raw() http.ResponseWriter { return c.writer }
|
|
|
|
// Clone gives a buffering middleware its own response state and values map.
|
|
// 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
|
|
}
|
|
return clone
|
|
}
|
|
|
|
// WriteResponse commits a buffered response through GoOmni's response tracking.
|
|
func (c *Context) WriteResponse(status int, headers http.Header, body []byte) error {
|
|
if c.responded {
|
|
return nil
|
|
}
|
|
if c.writer == nil {
|
|
return apierrors.Internal(nil)
|
|
}
|
|
for key, values := range headers {
|
|
c.writer.Header()[key] = append([]string(nil), values...)
|
|
}
|
|
c.responded, c.status = true, status
|
|
c.writer.WriteHeader(status)
|
|
if len(body) > 0 {
|
|
if _, err := c.writer.Write(body); err != nil {
|
|
return apierrors.Internal(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Context) Param(name string) string { return c.params[name] }
|
|
|
|
func (c *Context) Query(name string) string {
|
|
if c.request == nil {
|
|
return ""
|
|
}
|
|
return c.request.URL.Query().Get(name)
|
|
}
|
|
|
|
func (c *Context) Header(name string) string {
|
|
if c.request == nil {
|
|
return ""
|
|
}
|
|
return c.request.Header.Get(name)
|
|
}
|
|
|
|
func (c *Context) RequestID() string { return logx.RequestID(c.Context()) }
|
|
|
|
func (c *Context) Set(key string, value any) { c.values[key] = value }
|
|
|
|
func (c *Context) Get(key string) (any, bool) {
|
|
value, ok := c.values[key]
|
|
return value, ok
|
|
}
|
|
|
|
// WithContext replaces the standard request context while preserving the
|
|
// request and its headers.
|
|
func (c *Context) WithContext(ctx context.Context) {
|
|
if c.request != nil {
|
|
c.request = c.request.WithContext(ctx)
|
|
}
|
|
}
|
|
|
|
// SetResponseHeader sets a header on the underlying response.
|
|
func (c *Context) SetResponseHeader(name, value string) {
|
|
if c.writer != nil {
|
|
c.writer.Header().Set(name, value)
|
|
}
|
|
}
|
|
|
|
func (c *Context) BindJSON(value any) error { return validate.JSON(c.request, value) }
|
|
|
|
func (c *Context) BindQuery(value any) error { return validate.Query(c.request, value) }
|
|
|
|
func (c *Context) BindURI(value any) error { return validate.URI(c.params, value) }
|
|
|
|
// JSON is an escape hatch for responses that do not use the standard envelope.
|
|
func (c *Context) JSON(status int, value any) error {
|
|
if c.responded {
|
|
return nil
|
|
}
|
|
if c.writer == nil {
|
|
return apierrors.Internal(nil)
|
|
}
|
|
// Encode before committing headers so an invalid value can still produce
|
|
// the standard 500 response on every adapter.
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return apierrors.Internal(err)
|
|
}
|
|
c.responded = true
|
|
c.status = status
|
|
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
c.writer.WriteHeader(status)
|
|
if _, err := c.writer.Write(append(data, '\n')); err != nil {
|
|
return apierrors.Internal(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Context) OK(data any) error { return c.respond(http.StatusOK, "OK", "success", data) }
|
|
|
|
func (c *Context) Created(data any) error {
|
|
return c.respond(http.StatusCreated, "CREATED", "created", data)
|
|
}
|
|
|
|
func (c *Context) Accepted(data any) error {
|
|
return c.respond(http.StatusAccepted, "ACCEPTED", "accepted", data)
|
|
}
|
|
|
|
func (c *Context) NoContent() error {
|
|
if c.responded {
|
|
return nil
|
|
}
|
|
if c.writer == nil {
|
|
return apierrors.Internal(nil)
|
|
}
|
|
c.responded = true
|
|
c.status = http.StatusNoContent
|
|
c.writer.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func (c *Context) respond(status int, code, message string, data any) error {
|
|
return c.writeResult(response.Result{
|
|
Status: status,
|
|
Code: code,
|
|
Message: message,
|
|
Data: data,
|
|
RequestID: c.RequestID(),
|
|
})
|
|
}
|
|
|
|
// Status returns the response status observed by Context.
|
|
func (c *Context) Status() int {
|
|
if c.status == 0 {
|
|
return http.StatusOK
|
|
}
|
|
return c.status
|
|
}
|
|
|
|
// Responded reports whether this handler has already written a response.
|
|
func (c *Context) Responded() bool { return c.responded }
|
|
|
|
// WriteError writes a stable public error response. Internal causes are not
|
|
// 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 || err == nil {
|
|
return
|
|
}
|
|
apiErr := apierrors.From(err)
|
|
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)
|
|
}
|
|
}
|