Compare commits
1 commit
dev
...
ax/review-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43abce034e |
128 changed files with 1366 additions and 19647 deletions
196
api.go
196
api.go
|
|
@ -6,13 +6,12 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"iter"
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/gin-contrib/expvar"
|
"github.com/gin-contrib/expvar"
|
||||||
"github.com/gin-contrib/pprof"
|
"github.com/gin-contrib/pprof"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
@ -25,57 +24,27 @@ const defaultAddr = ":8080"
|
||||||
const shutdownTimeout = 10 * time.Second
|
const shutdownTimeout = 10 * time.Second
|
||||||
|
|
||||||
// Engine is the central API server managing route groups and middleware.
|
// Engine is the central API server managing route groups and middleware.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, err := api.New(api.WithAddr(":8081"))
|
|
||||||
// if err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
// _ = engine.Handler()
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
addr string
|
addr string
|
||||||
groups []RouteGroup
|
groups []RouteGroup
|
||||||
middlewares []gin.HandlerFunc
|
middlewares []gin.HandlerFunc
|
||||||
cacheTTL time.Duration
|
wsHandler http.Handler
|
||||||
cacheMaxEntries int
|
sseBroker *SSEBroker
|
||||||
cacheMaxBytes int
|
swaggerEnabled bool
|
||||||
wsHandler http.Handler
|
swaggerTitle string
|
||||||
wsPath string
|
swaggerDesc string
|
||||||
sseBroker *SSEBroker
|
swaggerVersion string
|
||||||
swaggerEnabled bool
|
pprofEnabled bool
|
||||||
swaggerTitle string
|
expvarEnabled bool
|
||||||
swaggerSummary string
|
graphql *graphqlConfig
|
||||||
swaggerDesc string
|
|
||||||
swaggerVersion string
|
|
||||||
swaggerPath string
|
|
||||||
swaggerTermsOfService string
|
|
||||||
swaggerServers []string
|
|
||||||
swaggerContactName string
|
|
||||||
swaggerContactURL string
|
|
||||||
swaggerContactEmail string
|
|
||||||
swaggerLicenseName string
|
|
||||||
swaggerLicenseURL string
|
|
||||||
swaggerSecuritySchemes map[string]any
|
|
||||||
swaggerExternalDocsDescription string
|
|
||||||
swaggerExternalDocsURL string
|
|
||||||
authentikConfig AuthentikConfig
|
|
||||||
pprofEnabled bool
|
|
||||||
expvarEnabled bool
|
|
||||||
ssePath string
|
|
||||||
graphql *graphqlConfig
|
|
||||||
i18nConfig I18nConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Engine with the given options.
|
// New creates an Engine with the given options.
|
||||||
// The default listen address is ":8080".
|
// The default listen address is ":8080".
|
||||||
//
|
//
|
||||||
// Example:
|
// engine, _ := api.New(api.WithAddr(":9090"), api.WithCORS("*"))
|
||||||
//
|
// engine.Register(myGroup)
|
||||||
// engine, err := api.New(api.WithAddr(":8081"), api.WithResponseMeta())
|
// engine.Serve(ctx)
|
||||||
// if err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
func New(opts ...Option) (*Engine, error) {
|
func New(opts ...Option) (*Engine, error) {
|
||||||
e := &Engine{
|
e := &Engine{
|
||||||
addr: defaultAddr,
|
addr: defaultAddr,
|
||||||
|
|
@ -88,77 +57,49 @@ func New(opts ...Option) (*Engine, error) {
|
||||||
|
|
||||||
// Addr returns the configured listen address.
|
// Addr returns the configured listen address.
|
||||||
//
|
//
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithAddr(":9090"))
|
// engine, _ := api.New(api.WithAddr(":9090"))
|
||||||
// addr := engine.Addr()
|
// addr := engine.Addr() // ":9090"
|
||||||
func (e *Engine) Addr() string {
|
func (e *Engine) Addr() string {
|
||||||
return e.addr
|
return e.addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups returns a copy of all registered route groups.
|
// Groups returns all registered route groups.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// groups := engine.Groups()
|
|
||||||
func (e *Engine) Groups() []RouteGroup {
|
func (e *Engine) Groups() []RouteGroup {
|
||||||
return slices.Clone(e.groups)
|
return e.groups
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupsIter returns an iterator over all registered route groups.
|
// GroupsIter returns an iterator over all registered route groups.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for group := range engine.GroupsIter() {
|
|
||||||
// _ = group
|
|
||||||
// }
|
|
||||||
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
|
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
|
||||||
groups := slices.Clone(e.groups)
|
return slices.Values(e.groups)
|
||||||
return slices.Values(groups)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a route group to the engine.
|
// Register adds a route group to the engine.
|
||||||
//
|
//
|
||||||
// Example:
|
// engine.Register(api.NewToolBridge("/tools"))
|
||||||
//
|
// engine.Register(myRouteGroup)
|
||||||
// engine.Register(myGroup)
|
|
||||||
func (e *Engine) Register(group RouteGroup) {
|
func (e *Engine) Register(group RouteGroup) {
|
||||||
if isNilRouteGroup(group) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.groups = append(e.groups, group)
|
e.groups = append(e.groups, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels returns all WebSocket channel names from registered StreamGroups.
|
// Channels returns all WebSocket channel names from registered StreamGroups.
|
||||||
// Groups that do not implement StreamGroup are silently skipped.
|
// Groups that do not implement StreamGroup are silently skipped.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// channels := engine.Channels()
|
|
||||||
func (e *Engine) Channels() []string {
|
func (e *Engine) Channels() []string {
|
||||||
var channels []string
|
var channels []string
|
||||||
for _, g := range e.groups {
|
for _, group := range e.groups {
|
||||||
if sg, ok := g.(StreamGroup); ok {
|
if streamGroup, ok := group.(StreamGroup); ok {
|
||||||
channels = append(channels, sg.Channels()...)
|
channels = append(channels, streamGroup.Channels()...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
|
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for channel := range engine.ChannelsIter() {
|
|
||||||
// _ = channel
|
|
||||||
// }
|
|
||||||
func (e *Engine) ChannelsIter() iter.Seq[string] {
|
func (e *Engine) ChannelsIter() iter.Seq[string] {
|
||||||
groups := slices.Clone(e.groups)
|
|
||||||
return func(yield func(string) bool) {
|
return func(yield func(string) bool) {
|
||||||
for _, g := range groups {
|
for _, group := range e.groups {
|
||||||
if sg, ok := g.(StreamGroup); ok {
|
if streamGroup, ok := group.(StreamGroup); ok {
|
||||||
for _, c := range sg.Channels() {
|
for _, channelName := range streamGroup.Channels() {
|
||||||
if !yield(c) {
|
if !yield(channelName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,53 +111,35 @@ func (e *Engine) ChannelsIter() iter.Seq[string] {
|
||||||
// Handler builds the Gin engine and returns it as an http.Handler.
|
// Handler builds the Gin engine and returns it as an http.Handler.
|
||||||
// Each call produces a fresh handler reflecting the current set of groups.
|
// Each call produces a fresh handler reflecting the current set of groups.
|
||||||
//
|
//
|
||||||
// Example:
|
// http.ListenAndServe(":8080", engine.Handler())
|
||||||
//
|
|
||||||
// handler := engine.Handler()
|
|
||||||
func (e *Engine) Handler() http.Handler {
|
func (e *Engine) Handler() http.Handler {
|
||||||
return e.build()
|
return e.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve starts the HTTP server and blocks until the context is cancelled,
|
// Serve starts the HTTP server and blocks until the context is cancelled,
|
||||||
// then performs a graceful shutdown allowing in-flight requests to complete.
|
// then performs a graceful shutdown allowing in-flight requests to complete.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
// defer cancel()
|
|
||||||
// _ = engine.Serve(ctx)
|
|
||||||
func (e *Engine) Serve(ctx context.Context) error {
|
func (e *Engine) Serve(ctx context.Context) error {
|
||||||
srv := &http.Server{
|
server := &http.Server{
|
||||||
Addr: e.addr,
|
Addr: e.addr,
|
||||||
Handler: e.build(),
|
Handler: e.build(),
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := server.ListenAndServe(); err != nil && !coreerr.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
close(errCh)
|
close(errCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Return immediately if the listener fails before shutdown is requested.
|
// Block until context is cancelled.
|
||||||
select {
|
<-ctx.Done()
|
||||||
case err := <-errCh:
|
|
||||||
return err
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal SSE clients first so their handlers can exit cleanly before the
|
|
||||||
// HTTP server begins its own shutdown sequence.
|
|
||||||
if e.sseBroker != nil {
|
|
||||||
e.sseBroker.Drain()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown with timeout.
|
// Graceful shutdown with timeout.
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := server.Shutdown(shutdownContext); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,71 +150,54 @@ func (e *Engine) Serve(ctx context.Context) error {
|
||||||
// build creates a configured Gin engine with recovery middleware,
|
// build creates a configured Gin engine with recovery middleware,
|
||||||
// user-supplied middleware, the health endpoint, and all registered route groups.
|
// user-supplied middleware, the health endpoint, and all registered route groups.
|
||||||
func (e *Engine) build() *gin.Engine {
|
func (e *Engine) build() *gin.Engine {
|
||||||
r := gin.New()
|
router := gin.New()
|
||||||
r.Use(recoveryMiddleware())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
// Apply user-supplied middleware after recovery but before routes.
|
// Apply user-supplied middleware after recovery but before routes.
|
||||||
for _, mw := range e.middlewares {
|
for _, middleware := range e.middlewares {
|
||||||
r.Use(mw)
|
router.Use(middleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in health check.
|
// Built-in health check.
|
||||||
r.GET("/health", func(c *gin.Context) {
|
router.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, OK("healthy"))
|
c.JSON(http.StatusOK, OK("healthy"))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mount each registered group at its base path.
|
// Mount each registered group at its base path.
|
||||||
for _, g := range e.groups {
|
for _, group := range e.groups {
|
||||||
if isNilRouteGroup(g) {
|
routerGroup := router.Group(group.BasePath())
|
||||||
continue
|
group.RegisterRoutes(routerGroup)
|
||||||
}
|
|
||||||
rg := r.Group(g.BasePath())
|
|
||||||
g.RegisterRoutes(rg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount WebSocket handler if configured.
|
// Mount WebSocket handler if configured.
|
||||||
if e.wsHandler != nil {
|
if e.wsHandler != nil {
|
||||||
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
|
router.GET("/ws", wrapWSHandler(e.wsHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount SSE endpoint if configured.
|
// Mount SSE endpoint if configured.
|
||||||
if e.sseBroker != nil {
|
if e.sseBroker != nil {
|
||||||
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
|
router.GET("/events", e.sseBroker.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount GraphQL endpoint if configured.
|
// Mount GraphQL endpoint if configured.
|
||||||
if e.graphql != nil {
|
if e.graphql != nil {
|
||||||
mountGraphQL(r, e.graphql)
|
mountGraphQL(router, e.graphql)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount Swagger UI if enabled.
|
// Mount Swagger UI if enabled.
|
||||||
if e.swaggerEnabled {
|
if e.swaggerEnabled {
|
||||||
registerSwagger(r, e, e.groups)
|
registerSwagger(router, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount pprof profiling endpoints if enabled.
|
// Mount pprof profiling endpoints if enabled.
|
||||||
if e.pprofEnabled {
|
if e.pprofEnabled {
|
||||||
pprof.Register(r)
|
pprof.Register(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount expvar runtime metrics endpoint if enabled.
|
// Mount expvar runtime metrics endpoint if enabled.
|
||||||
if e.expvarEnabled {
|
if e.expvarEnabled {
|
||||||
r.GET("/debug/vars", expvar.Handler())
|
router.GET("/debug/vars", expvar.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return router
|
||||||
}
|
|
||||||
|
|
||||||
func isNilRouteGroup(group RouteGroup) bool {
|
|
||||||
if group == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
value := reflect.ValueOf(group)
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
||||||
return value.IsNil()
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
api_test.go
104
api_test.go
|
|
@ -29,16 +29,6 @@ func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type panicGroup struct{}
|
|
||||||
|
|
||||||
func (p *panicGroup) Name() string { return "panic" }
|
|
||||||
func (p *panicGroup) BasePath() string { return "/panic" }
|
|
||||||
func (p *panicGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/boom", func(c *gin.Context) {
|
|
||||||
panic("boom")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── New ─────────────────────────────────────────────────────────────────
|
// ── New ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestNew_Good(t *testing.T) {
|
func TestNew_Good(t *testing.T) {
|
||||||
|
|
@ -95,28 +85,6 @@ func TestRegister_Good_MultipleGroups(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegister_Good_GroupsReturnsCopy(t *testing.T) {
|
|
||||||
e, _ := api.New()
|
|
||||||
first := &healthGroup{}
|
|
||||||
second := &stubGroup{}
|
|
||||||
e.Register(first)
|
|
||||||
e.Register(second)
|
|
||||||
|
|
||||||
groups := e.Groups()
|
|
||||||
groups[0] = nil
|
|
||||||
|
|
||||||
fresh := e.Groups()
|
|
||||||
if fresh[0] == nil {
|
|
||||||
t.Fatal("expected Groups to return a copy, but engine state was mutated")
|
|
||||||
}
|
|
||||||
if fresh[0].Name() != first.Name() {
|
|
||||||
t.Fatalf("expected first group name %q, got %q", first.Name(), fresh[0].Name())
|
|
||||||
}
|
|
||||||
if fresh[1].Name() != "stub" {
|
|
||||||
t.Fatalf("expected second group name %q, got %q", "stub", fresh[1].Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Handler ─────────────────────────────────────────────────────────────
|
// ── Handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestHandler_Good_HealthEndpoint(t *testing.T) {
|
func TestHandler_Good_HealthEndpoint(t *testing.T) {
|
||||||
|
|
@ -181,41 +149,6 @@ func TestHandler_Bad_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_Bad_PanicReturnsEnvelope(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRequestID())
|
|
||||||
e.Register(&panicGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/panic/boom", nil)
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Fatalf("expected 500, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil {
|
|
||||||
t.Fatal("expected Error to be non-nil")
|
|
||||||
}
|
|
||||||
if resp.Error.Code != "internal_server_error" {
|
|
||||||
t.Fatalf("expected error code=%q, got %q", "internal_server_error", resp.Error.Code)
|
|
||||||
}
|
|
||||||
if resp.Error.Message != "Internal server error" {
|
|
||||||
t.Fatalf("expected error message=%q, got %q", "Internal server error", resp.Error.Message)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("X-Request-ID"); got == "" {
|
|
||||||
t.Fatal("expected X-Request-ID header to survive panic recovery")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Serve + graceful shutdown ───────────────────────────────────────────
|
// ── Serve + graceful shutdown ───────────────────────────────────────────
|
||||||
|
|
||||||
func TestServe_Good_GracefulShutdown(t *testing.T) {
|
func TestServe_Good_GracefulShutdown(t *testing.T) {
|
||||||
|
|
@ -270,31 +203,20 @@ func TestServe_Good_GracefulShutdown(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServe_Bad_ReturnsListenErrorBeforeCancel(t *testing.T) {
|
func TestNew_Ugly_MultipleOptionsDontPanic(t *testing.T) {
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
defer func() {
|
||||||
if err != nil {
|
if r := recover(); r != nil {
|
||||||
t.Fatalf("failed to reserve port: %v", err)
|
t.Fatalf("New with many options panicked: %v", r)
|
||||||
}
|
}
|
||||||
addr := ln.Addr().String()
|
|
||||||
defer ln.Close()
|
|
||||||
|
|
||||||
e, _ := api.New(api.WithAddr(addr))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
errCh <- e.Serve(ctx)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
// Applying many options at once should not panic.
|
||||||
case serveErr := <-errCh:
|
_, err := api.New(
|
||||||
if serveErr == nil {
|
api.WithAddr(":0"),
|
||||||
t.Fatal("expected Serve to return a listen error, got nil")
|
api.WithRequestID(),
|
||||||
}
|
api.WithCORS("*"),
|
||||||
case <-time.After(2 * time.Second):
|
)
|
||||||
cancel()
|
if err != nil {
|
||||||
t.Fatal("Serve did not return promptly after listener failure")
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
authentik.go
138
authentik.go
|
|
@ -6,18 +6,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthentikConfig holds settings for the Authentik forward-auth integration.
|
// AuthentikConfig holds settings for the Authentik forward-auth integration.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := api.AuthentikConfig{Issuer: "https://auth.example.com/", ClientID: "core-api"}
|
|
||||||
type AuthentikConfig struct {
|
type AuthentikConfig struct {
|
||||||
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
|
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
|
||||||
Issuer string
|
Issuer string
|
||||||
|
|
@ -30,32 +26,12 @@ type AuthentikConfig struct {
|
||||||
TrustedProxy bool
|
TrustedProxy bool
|
||||||
|
|
||||||
// PublicPaths lists additional paths that do not require authentication.
|
// PublicPaths lists additional paths that do not require authentication.
|
||||||
// /health and the configured Swagger UI path are always public.
|
// /health and /swagger are always public.
|
||||||
PublicPaths []string
|
PublicPaths []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthentikConfig returns the configured Authentik settings for the engine.
|
|
||||||
//
|
|
||||||
// The result snapshots the Engine state at call time and clones slices so
|
|
||||||
// callers can safely reuse or modify the returned value.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.AuthentikConfig()
|
|
||||||
func (e *Engine) AuthentikConfig() AuthentikConfig {
|
|
||||||
if e == nil {
|
|
||||||
return AuthentikConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneAuthentikConfig(e.authentikConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthentikUser represents an authenticated user extracted from Authentik
|
// AuthentikUser represents an authenticated user extracted from Authentik
|
||||||
// forward-auth headers or a validated JWT.
|
// forward-auth headers or a validated JWT.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// user := &api.AuthentikUser{Username: "alice", Groups: []string{"admins"}}
|
|
||||||
type AuthentikUser struct {
|
type AuthentikUser struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
@ -68,9 +44,8 @@ type AuthentikUser struct {
|
||||||
|
|
||||||
// HasGroup reports whether the user belongs to the named group.
|
// HasGroup reports whether the user belongs to the named group.
|
||||||
//
|
//
|
||||||
// Example:
|
// user := api.GetUser(c)
|
||||||
//
|
// if user.HasGroup("admins") { /* allow */ }
|
||||||
// user.HasGroup("admins")
|
|
||||||
func (u *AuthentikUser) HasGroup(group string) bool {
|
func (u *AuthentikUser) HasGroup(group string) bool {
|
||||||
return slices.Contains(u.Groups, group)
|
return slices.Contains(u.Groups, group)
|
||||||
}
|
}
|
||||||
|
|
@ -82,9 +57,8 @@ const authentikUserKey = "authentik_user"
|
||||||
// Returns nil when no user has been set (unauthenticated request or
|
// Returns nil when no user has been set (unauthenticated request or
|
||||||
// middleware not active).
|
// middleware not active).
|
||||||
//
|
//
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// user := api.GetUser(c)
|
// user := api.GetUser(c)
|
||||||
|
// if user == nil { c.AbortWithStatus(401); return }
|
||||||
func GetUser(c *gin.Context) *AuthentikUser {
|
func GetUser(c *gin.Context) *AuthentikUser {
|
||||||
val, exists := c.Get(authentikUserKey)
|
val, exists := c.Get(authentikUserKey)
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -110,28 +84,28 @@ func getOIDCProvider(ctx context.Context, issuer string) (*oidc.Provider, error)
|
||||||
oidcProviderMu.Lock()
|
oidcProviderMu.Lock()
|
||||||
defer oidcProviderMu.Unlock()
|
defer oidcProviderMu.Unlock()
|
||||||
|
|
||||||
if p, ok := oidcProviders[issuer]; ok {
|
if provider, ok := oidcProviders[issuer]; ok {
|
||||||
return p, nil
|
return provider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := oidc.NewProvider(ctx, issuer)
|
provider, err := oidc.NewProvider(ctx, issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcProviders[issuer] = p
|
oidcProviders[issuer] = provider
|
||||||
return p, nil
|
return provider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateJWT verifies a raw JWT against the configured OIDC issuer and
|
// validateJWT verifies a raw JWT against the configured OIDC issuer and
|
||||||
// extracts user claims on success.
|
// extracts user claims on success.
|
||||||
func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*AuthentikUser, error) {
|
func validateJWT(ctx context.Context, config AuthentikConfig, rawToken string) (*AuthentikUser, error) {
|
||||||
provider, err := getOIDCProvider(ctx, cfg.Issuer)
|
provider, err := getOIDCProvider(ctx, config.Issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
|
verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})
|
||||||
|
|
||||||
idToken, err := verifier.Verify(ctx, rawToken)
|
idToken, err := verifier.Verify(ctx, rawToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -166,36 +140,28 @@ func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*Au
|
||||||
// The middleware is PERMISSIVE: it populates the context when credentials are
|
// The middleware is PERMISSIVE: it populates the context when credentials are
|
||||||
// present but never rejects unauthenticated requests. Downstream handlers
|
// present but never rejects unauthenticated requests. Downstream handlers
|
||||||
// use GetUser to check authentication.
|
// use GetUser to check authentication.
|
||||||
func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.HandlerFunc {
|
func authentikMiddleware(config AuthentikConfig) gin.HandlerFunc {
|
||||||
// Build the set of public paths that skip header extraction entirely.
|
// Build the set of public paths that skip header extraction entirely.
|
||||||
public := map[string]bool{
|
public := map[string]bool{
|
||||||
"/health": true,
|
"/health": true,
|
||||||
"/swagger": true,
|
"/swagger": true,
|
||||||
}
|
}
|
||||||
for _, p := range cfg.PublicPaths {
|
for _, publicPath := range config.PublicPaths {
|
||||||
public[p] = true
|
public[publicPath] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Skip public paths.
|
// Skip public paths.
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
for p := range public {
|
for publicPath := range public {
|
||||||
if isPublicPath(path, p) {
|
if core.HasPrefix(path, publicPath) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if publicPaths != nil {
|
|
||||||
for _, p := range publicPaths() {
|
|
||||||
if isPublicPath(path, p) {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block 1: Extract user from X-authentik-* forward-auth headers.
|
// Block 1: Extract user from X-authentik-* forward-auth headers.
|
||||||
if cfg.TrustedProxy {
|
if config.TrustedProxy {
|
||||||
username := c.GetHeader("X-authentik-username")
|
username := c.GetHeader("X-authentik-username")
|
||||||
if username != "" {
|
if username != "" {
|
||||||
user := &AuthentikUser{
|
user := &AuthentikUser{
|
||||||
|
|
@ -207,10 +173,10 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H
|
||||||
}
|
}
|
||||||
|
|
||||||
if groups := c.GetHeader("X-authentik-groups"); groups != "" {
|
if groups := c.GetHeader("X-authentik-groups"); groups != "" {
|
||||||
user.Groups = strings.Split(groups, "|")
|
user.Groups = core.Split(groups, "|")
|
||||||
}
|
}
|
||||||
if ent := c.GetHeader("X-authentik-entitlements"); ent != "" {
|
if ent := c.GetHeader("X-authentik-entitlements"); ent != "" {
|
||||||
user.Entitlements = strings.Split(ent, "|")
|
user.Entitlements = core.Split(ent, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(authentikUserKey, user)
|
c.Set(authentikUserKey, user)
|
||||||
|
|
@ -219,10 +185,10 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H
|
||||||
|
|
||||||
// Block 2: Attempt JWT validation for direct API clients.
|
// Block 2: Attempt JWT validation for direct API clients.
|
||||||
// Only when OIDC is configured and no user was extracted from headers.
|
// Only when OIDC is configured and no user was extracted from headers.
|
||||||
if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil {
|
if config.Issuer != "" && config.ClientID != "" && GetUser(c) == nil {
|
||||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
if auth := c.GetHeader("Authorization"); core.HasPrefix(auth, "Bearer ") {
|
||||||
rawToken := strings.TrimPrefix(auth, "Bearer ")
|
rawToken := core.TrimPrefix(auth, "Bearer ")
|
||||||
if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil {
|
if user, err := validateJWT(c.Request.Context(), config, rawToken); err == nil {
|
||||||
c.Set(authentikUserKey, user)
|
c.Set(authentikUserKey, user)
|
||||||
}
|
}
|
||||||
// On failure: continue without user (fail open / permissive).
|
// On failure: continue without user (fail open / permissive).
|
||||||
|
|
@ -233,57 +199,12 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneAuthentikConfig(cfg AuthentikConfig) AuthentikConfig {
|
|
||||||
out := cfg
|
|
||||||
out.Issuer = strings.TrimSpace(out.Issuer)
|
|
||||||
out.ClientID = strings.TrimSpace(out.ClientID)
|
|
||||||
out.PublicPaths = normalisePublicPaths(cfg.PublicPaths)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalisePublicPaths trims whitespace, ensures a leading slash, and removes
|
|
||||||
// duplicate entries while preserving the first occurrence of each path.
|
|
||||||
func normalisePublicPaths(paths []string) []string {
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(paths))
|
|
||||||
seen := make(map[string]struct{}, len(paths))
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
path = strings.TrimRight(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
if _, ok := seen[path]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[path] = struct{}{}
|
|
||||||
out = append(out, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireAuth is Gin middleware that rejects unauthenticated requests.
|
// RequireAuth is Gin middleware that rejects unauthenticated requests.
|
||||||
// It checks for a user set by the Authentik middleware and returns 401
|
// It checks for a user set by the Authentik middleware and returns 401
|
||||||
// when none is present.
|
// when none is present.
|
||||||
//
|
//
|
||||||
// Example:
|
// rg := router.Group("/api", api.RequireAuth())
|
||||||
//
|
// rg.GET("/profile", profileHandler)
|
||||||
// r.GET("/private", api.RequireAuth(), handler)
|
|
||||||
func RequireAuth() gin.HandlerFunc {
|
func RequireAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if GetUser(c) == nil {
|
if GetUser(c) == nil {
|
||||||
|
|
@ -299,9 +220,8 @@ func RequireAuth() gin.HandlerFunc {
|
||||||
// not belong to the specified group. Returns 401 when no user is present
|
// not belong to the specified group. Returns 401 when no user is present
|
||||||
// and 403 when the user lacks the required group membership.
|
// and 403 when the user lacks the required group membership.
|
||||||
//
|
//
|
||||||
// Example:
|
// rg := router.Group("/admin", api.RequireGroup("admins"))
|
||||||
//
|
// rg.DELETE("/users/:id", deleteUserHandler)
|
||||||
// r.GET("/admin", api.RequireGroup("admins"), handler)
|
|
||||||
func RequireGroup(group string) gin.HandlerFunc {
|
func RequireGroup(group string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
user := GetUser(c)
|
user := GetUser(c)
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,14 @@
|
||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
api "dappco.re/go/core/api"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -43,58 +41,68 @@ func getClientCredentialsToken(t *testing.T, issuer, clientID, clientSecret stri
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Discover token endpoint.
|
// Discover token endpoint.
|
||||||
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||||
resp, err := http.Get(disc)
|
resp, err := http.Get(discoveryURL) //nolint:noctx
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OIDC discovery failed: %v", err)
|
t.Fatalf("OIDC discovery failed: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var config struct {
|
discoveryBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read discovery body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcConfig struct {
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
if result := core.JSONUnmarshal(discoveryBody, &oidcConfig); !result.OK {
|
||||||
t.Fatalf("decode discovery: %v", err)
|
t.Fatalf("decode discovery: %v", result.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request token.
|
// Request token.
|
||||||
data := url.Values{
|
formData := url.Values{
|
||||||
"grant_type": {"client_credentials"},
|
"grant_type": {"client_credentials"},
|
||||||
"client_id": {clientID},
|
"client_id": {clientID},
|
||||||
"client_secret": {clientSecret},
|
"client_secret": {clientSecret},
|
||||||
"scope": {"openid email profile entitlements"},
|
"scope": {"openid email profile entitlements"},
|
||||||
}
|
}
|
||||||
resp, err = http.PostForm(config.TokenEndpoint, data)
|
tokenResp, err := http.PostForm(oidcConfig.TokenEndpoint, formData) //nolint:noctx
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("token request failed: %v", err)
|
t.Fatalf("token request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer tokenResp.Body.Close()
|
||||||
|
|
||||||
var tokenResp struct {
|
tokenBody, err := io.ReadAll(tokenResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read token body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResult struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
IDToken string `json:"id_token"`
|
IDToken string `json:"id_token"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
ErrorDesc string `json:"error_description"`
|
ErrorDesc string `json:"error_description"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
if result := core.JSONUnmarshal(tokenBody, &tokenResult); !result.OK {
|
||||||
t.Fatalf("decode token response: %v", err)
|
t.Fatalf("decode token response: %v", result.Value)
|
||||||
}
|
}
|
||||||
if tokenResp.Error != "" {
|
if tokenResult.Error != "" {
|
||||||
t.Fatalf("token error: %s — %s", tokenResp.Error, tokenResp.ErrorDesc)
|
t.Fatalf("token error: %s — %s", tokenResult.Error, tokenResult.ErrorDesc)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenResp.AccessToken, tokenResp.IDToken
|
return tokenResult.AccessToken, tokenResult.IDToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthentikIntegration(t *testing.T) {
|
func TestAuthentikIntegration_Good_LiveTokenFlow(t *testing.T) {
|
||||||
// Skip unless explicitly enabled — requires live Authentik at auth.lthn.io.
|
// Skip unless explicitly enabled — requires live Authentik at auth.lthn.io.
|
||||||
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
|
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
||||||
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||||
clientID := envOr("AUTHENTIK_CLIENT_ID", "core-api")
|
clientID := envOrDefault("AUTHENTIK_CLIENT_ID", "core-api")
|
||||||
clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET")
|
clientSecret := core.Env("AUTHENTIK_CLIENT_SECRET")
|
||||||
if clientSecret == "" {
|
if clientSecret == "" {
|
||||||
t.Fatal("AUTHENTIK_CLIENT_SECRET is required")
|
t.Fatal("AUTHENTIK_CLIENT_SECRET is required")
|
||||||
}
|
}
|
||||||
|
|
@ -126,60 +134,60 @@ func TestAuthentikIntegration(t *testing.T) {
|
||||||
t.Fatalf("engine: %v", err)
|
t.Fatalf("engine: %v", err)
|
||||||
}
|
}
|
||||||
engine.Register(&testAuthRoutes{})
|
engine.Register(&testAuthRoutes{})
|
||||||
ts := httptest.NewServer(engine.Handler())
|
testServer := httptest.NewServer(engine.Handler())
|
||||||
defer ts.Close()
|
defer testServer.Close()
|
||||||
|
|
||||||
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
|
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
|
||||||
|
|
||||||
t.Run("Health_NoAuth", func(t *testing.T) {
|
t.Run("Health_NoAuth", func(t *testing.T) {
|
||||||
resp := get(t, ts.URL+"/health", "")
|
resp := getWithBearer(t, testServer.URL+"/health", "")
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
body := readBody(t, resp)
|
body := readResponseBody(t, resp)
|
||||||
t.Logf("health: %s", body)
|
t.Logf("health: %s", body)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Public_NoAuth", func(t *testing.T) {
|
t.Run("Public_NoAuth", func(t *testing.T) {
|
||||||
resp := get(t, ts.URL+"/v1/public", "")
|
resp := getWithBearer(t, testServer.URL+"/v1/public", "")
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
body := readBody(t, resp)
|
body := readResponseBody(t, resp)
|
||||||
t.Logf("public: %s", body)
|
t.Logf("public: %s", body)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Whoami_NoToken_401", func(t *testing.T) {
|
t.Run("Whoami_NoToken_401", func(t *testing.T) {
|
||||||
resp := get(t, ts.URL+"/v1/whoami", "")
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "")
|
||||||
assertStatus(t, resp, 401)
|
assertStatusCode(t, resp, 401)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Whoami_WithAccessToken", func(t *testing.T) {
|
t.Run("Whoami_WithAccessToken", func(t *testing.T) {
|
||||||
resp := get(t, ts.URL+"/v1/whoami", accessToken)
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", accessToken)
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
body := readBody(t, resp)
|
body := readResponseBody(t, resp)
|
||||||
t.Logf("whoami (access_token): %s", body)
|
t.Logf("whoami (access_token): %s", body)
|
||||||
|
|
||||||
// Parse response and verify user fields.
|
// Parse response and verify user fields.
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data api.AuthentikUser `json:"data"`
|
Data api.AuthentikUser `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
||||||
t.Fatalf("parse whoami: %v", err)
|
t.Fatalf("parse whoami: %v", result.Value)
|
||||||
}
|
}
|
||||||
if envelope.Data.UID == "" {
|
if envelope.Data.UID == "" {
|
||||||
t.Error("expected non-empty UID")
|
t.Error("expected non-empty UID")
|
||||||
}
|
}
|
||||||
if !strings.Contains(envelope.Data.Username, "client_credentials") {
|
if !core.Contains(envelope.Data.Username, "client_credentials") {
|
||||||
t.Logf("username: %s (service account)", envelope.Data.Username)
|
t.Logf("username: %s (service account)", envelope.Data.Username)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Admin_ServiceAccount_403", func(t *testing.T) {
|
t.Run("Admin_ServiceAccount_403", func(t *testing.T) {
|
||||||
// Service account has no groups — should get 403.
|
// Service account has no groups — should get 403.
|
||||||
resp := get(t, ts.URL+"/v1/admin", accessToken)
|
resp := getWithBearer(t, testServer.URL+"/v1/admin", accessToken)
|
||||||
assertStatus(t, resp, 403)
|
assertStatusCode(t, resp, 403)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) {
|
t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) {
|
||||||
// Simulate what Traefik sends after forward auth.
|
// Simulate what Traefik sends after forward auth.
|
||||||
req, _ := http.NewRequest("GET", ts.URL+"/v1/whoami", nil)
|
req, _ := http.NewRequest("GET", testServer.URL+"/v1/whoami", nil)
|
||||||
req.Header.Set("X-authentik-username", "akadmin")
|
req.Header.Set("X-authentik-username", "akadmin")
|
||||||
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
||||||
req.Header.Set("X-authentik-name", "Admin User")
|
req.Header.Set("X-authentik-name", "Admin User")
|
||||||
|
|
@ -192,16 +200,16 @@ func TestAuthentikIntegration(t *testing.T) {
|
||||||
t.Fatalf("request: %v", err)
|
t.Fatalf("request: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
|
|
||||||
body := readBody(t, resp)
|
body := readResponseBody(t, resp)
|
||||||
t.Logf("whoami (forward auth): %s", body)
|
t.Logf("whoami (forward auth): %s", body)
|
||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data api.AuthentikUser `json:"data"`
|
Data api.AuthentikUser `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
||||||
t.Fatalf("parse: %v", err)
|
t.Fatalf("parse: %v", result.Value)
|
||||||
}
|
}
|
||||||
if envelope.Data.Username != "akadmin" {
|
if envelope.Data.Username != "akadmin" {
|
||||||
t.Errorf("expected username akadmin, got %s", envelope.Data.Username)
|
t.Errorf("expected username akadmin, got %s", envelope.Data.Username)
|
||||||
|
|
@ -212,7 +220,7 @@ func TestAuthentikIntegration(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Admin_ForwardAuth_Admins_200", func(t *testing.T) {
|
t.Run("Admin_ForwardAuth_Admins_200", func(t *testing.T) {
|
||||||
req, _ := http.NewRequest("GET", ts.URL+"/v1/admin", nil)
|
req, _ := http.NewRequest("GET", testServer.URL+"/v1/admin", nil)
|
||||||
req.Header.Set("X-authentik-username", "akadmin")
|
req.Header.Set("X-authentik-username", "akadmin")
|
||||||
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
||||||
req.Header.Set("X-authentik-name", "Admin User")
|
req.Header.Set("X-authentik-name", "Admin User")
|
||||||
|
|
@ -224,72 +232,72 @@ func TestAuthentikIntegration(t *testing.T) {
|
||||||
t.Fatalf("request: %v", err)
|
t.Fatalf("request: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
t.Logf("admin (forward auth): %s", readBody(t, resp))
|
t.Logf("admin (forward auth): %s", readResponseBody(t, resp))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("InvalidJWT_FailOpen", func(t *testing.T) {
|
t.Run("InvalidJWT_FailOpen", func(t *testing.T) {
|
||||||
// Invalid token on a public endpoint — should still work (permissive).
|
// Invalid token on a public endpoint — should still work (permissive).
|
||||||
resp := get(t, ts.URL+"/v1/public", "not-a-real-token")
|
resp := getWithBearer(t, testServer.URL+"/v1/public", "not-a-real-token")
|
||||||
assertStatus(t, resp, 200)
|
assertStatusCode(t, resp, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("InvalidJWT_Protected_401", func(t *testing.T) {
|
t.Run("InvalidJWT_Protected_401", func(t *testing.T) {
|
||||||
// Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401.
|
// Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401.
|
||||||
resp := get(t, ts.URL+"/v1/whoami", "not-a-real-token")
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "not-a-real-token")
|
||||||
assertStatus(t, resp, 401)
|
assertStatusCode(t, resp, 401)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func get(t *testing.T, url, bearerToken string) *http.Response {
|
func getWithBearer(t *testing.T, requestURL, bearerToken string) *http.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", requestURL, nil)
|
||||||
if bearerToken != "" {
|
if bearerToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GET %s: %v", url, err)
|
t.Fatalf("GET %s: %v", requestURL, err)
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBody(t *testing.T, resp *http.Response) string {
|
func readResponseBody(t *testing.T, resp *http.Response) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := io.ReadAll(resp.Body)
|
responseBytes, err := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read body: %v", err)
|
t.Fatalf("read body: %v", err)
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(responseBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertStatus(t *testing.T, resp *http.Response, want int) {
|
func assertStatusCode(t *testing.T, resp *http.Response, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if resp.StatusCode != want {
|
if resp.StatusCode != want {
|
||||||
b, _ := io.ReadAll(resp.Body)
|
responseBytes, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
t.Fatalf("want status %d, got %d: %s", want, resp.StatusCode, string(b))
|
t.Fatalf("want status %d, got %d: %s", want, resp.StatusCode, string(responseBytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envOr(key, fallback string) string {
|
func envOrDefault(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if value := core.Env(key); value != "" {
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOIDCDiscovery validates that the OIDC discovery endpoint is reachable.
|
// TestOIDCDiscovery_Good_EndpointReachable validates that the OIDC discovery endpoint is reachable.
|
||||||
func TestOIDCDiscovery(t *testing.T) {
|
func TestOIDCDiscovery_Good_EndpointReachable(t *testing.T) {
|
||||||
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
|
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
||||||
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||||
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
|
||||||
resp, err := http.Get(disc)
|
resp, err := http.Get(discoveryURL) //nolint:noctx
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("discovery request: %v", err)
|
t.Fatalf("discovery request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -299,39 +307,70 @@ func TestOIDCDiscovery(t *testing.T) {
|
||||||
t.Fatalf("discovery status: %d", resp.StatusCode)
|
t.Fatalf("discovery status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var config map[string]any
|
discoveryBody, err := io.ReadAll(resp.Body)
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("read discovery body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoveryConfig map[string]any
|
||||||
|
if result := core.JSONUnmarshal(discoveryBody, &discoveryConfig); !result.OK {
|
||||||
|
t.Fatalf("decode: %v", result.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify essential fields.
|
// Verify essential fields.
|
||||||
for _, field := range []string{"issuer", "token_endpoint", "jwks_uri", "authorization_endpoint"} {
|
for _, field := range []string{"issuer", "token_endpoint", "jwks_uri", "authorization_endpoint"} {
|
||||||
if config[field] == nil {
|
if discoveryConfig[field] == nil {
|
||||||
t.Errorf("missing field: %s", field)
|
t.Errorf("missing field: %s", field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config["issuer"] != issuer {
|
if discoveryConfig["issuer"] != issuer {
|
||||||
t.Errorf("issuer mismatch: got %v, want %s", config["issuer"], issuer)
|
t.Errorf("issuer mismatch: got %v, want %s", discoveryConfig["issuer"], issuer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify grant types include client_credentials.
|
// Verify grant types include client_credentials.
|
||||||
grants, ok := config["grant_types_supported"].([]any)
|
grants, ok := discoveryConfig["grant_types_supported"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("missing grant_types_supported")
|
t.Fatal("missing grant_types_supported")
|
||||||
}
|
}
|
||||||
found := false
|
clientCredentialsFound := false
|
||||||
for _, g := range grants {
|
for _, grantType := range grants {
|
||||||
if g == "client_credentials" {
|
if grantType == "client_credentials" {
|
||||||
found = true
|
clientCredentialsFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !clientCredentialsFound {
|
||||||
t.Error("client_credentials grant not supported")
|
t.Error("client_credentials grant not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" OIDC discovery OK — issuer: %s\n", config["issuer"])
|
t.Logf("OIDC discovery OK — issuer: %s", discoveryConfig["issuer"])
|
||||||
fmt.Printf(" Token endpoint: %s\n", config["token_endpoint"])
|
t.Logf("Token endpoint: %s", discoveryConfig["token_endpoint"])
|
||||||
fmt.Printf(" JWKS URI: %s\n", config["jwks_uri"])
|
t.Logf("JWKS URI: %s", discoveryConfig["jwks_uri"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOIDCDiscovery_Bad_SkipsWithoutEnvVar verifies the test skips without AUTHENTIK_INTEGRATION=1.
|
||||||
|
func TestOIDCDiscovery_Bad_SkipsWithoutEnvVar(t *testing.T) {
|
||||||
|
// This test always runs; it verifies no network call is made without the env var.
|
||||||
|
// Since we cannot unset env vars safely in parallel tests, we verify the skip logic
|
||||||
|
// by running this in an environment where AUTHENTIK_INTEGRATION is not "1".
|
||||||
|
if core.Env("AUTHENTIK_INTEGRATION") == "1" {
|
||||||
|
t.Skip("skipping skip-check test when integration env is set")
|
||||||
|
}
|
||||||
|
// No network call should happen — test passes if we reach here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOIDCDiscovery_Ugly_MalformedIssuerHandled verifies the discovery helper does not panic on bad issuer.
|
||||||
|
func TestOIDCDiscovery_Ugly_MalformedIssuerHandled(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("malformed issuer panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// envOrDefault returns fallback on empty — verify it does not panic on empty key.
|
||||||
|
result := envOrDefault("", "fallback")
|
||||||
|
if result != "fallback" {
|
||||||
|
t.Errorf("expected fallback, got %q", result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,27 +221,6 @@ func TestHealthBypassesAuthentik_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicPaths_Good_SimilarPrefixDoesNotBypassAuth(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
cfg := api.AuthentikConfig{
|
|
||||||
TrustedProxy: true,
|
|
||||||
PublicPaths: []string{"/public"},
|
|
||||||
}
|
|
||||||
e, _ := api.New(api.WithAuthentik(cfg))
|
|
||||||
e.Register(&publicPrefixGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/publicity/secure", nil)
|
|
||||||
req.Header.Set("X-authentik-username", "alice")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 for /publicity/secure with auth header, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetUser_Good_NilContext(t *testing.T) {
|
func TestGetUser_Good_NilContext(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|
@ -343,33 +322,6 @@ func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthentik_Good_CustomSwaggerPathBypassesAuth(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
cfg := api.AuthentikConfig{TrustedProxy: true}
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithAuthentik(cfg),
|
|
||||||
api.WithSwagger("Test API", "A test API service", "1.0.0"),
|
|
||||||
api.WithSwaggerPath("/docs"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := httptest.NewServer(e.Handler())
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/docs/doc.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 for custom swagger path without auth, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RequireAuth / RequireGroup ────────────────────────────────────────
|
// ── RequireAuth / RequireGroup ────────────────────────────────────────
|
||||||
|
|
||||||
func TestRequireAuth_Good(t *testing.T) {
|
func TestRequireAuth_Good(t *testing.T) {
|
||||||
|
|
@ -507,14 +459,40 @@ func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicPrefixGroup provides a route that should still be processed by auth
|
func TestAuthentikUser_Ugly_EmptyGroupsDontPanic(t *testing.T) {
|
||||||
// middleware even though its path shares a prefix with a public path.
|
defer func() {
|
||||||
type publicPrefixGroup struct{}
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("HasGroup on empty groups panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
func (g *publicPrefixGroup) Name() string { return "public-prefix" }
|
u := api.AuthentikUser{}
|
||||||
func (g *publicPrefixGroup) BasePath() string { return "/publicity" }
|
// HasGroup on a zero-value user (nil Groups slice) must not panic.
|
||||||
func (g *publicPrefixGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
if u.HasGroup("admins") {
|
||||||
rg.GET("/secure", api.RequireAuth(), func(c *gin.Context) {
|
t.Fatal("expected HasGroup to return false for empty user")
|
||||||
c.JSON(http.StatusOK, api.OK("protected"))
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
func TestGetUser_Ugly_WrongTypeInContext(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/test", func(c *gin.Context) {
|
||||||
|
// Inject a wrong type under the authentik key — GetUser must return nil.
|
||||||
|
c.Set("authentik_user", "not-a-user-struct")
|
||||||
|
user := api.GetUser(c)
|
||||||
|
if user != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, api.Fail("error", "unexpected user"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, api.OK("nil as expected"))
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
837
bridge.go
837
bridge.go
|
|
@ -3,30 +3,12 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"iter"
|
"iter"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
|
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// desc := api.ToolDescriptor{Name: "ping", Description: "Ping the service"}
|
|
||||||
type ToolDescriptor struct {
|
type ToolDescriptor struct {
|
||||||
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
|
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
|
||||||
Description string // Human-readable description
|
Description string // Human-readable description
|
||||||
|
|
@ -37,10 +19,6 @@ type ToolDescriptor struct {
|
||||||
|
|
||||||
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
|
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
|
||||||
// It implements both RouteGroup and DescribableGroup.
|
// It implements both RouteGroup and DescribableGroup.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// bridge := api.NewToolBridge("/mcp")
|
|
||||||
type ToolBridge struct {
|
type ToolBridge struct {
|
||||||
basePath string
|
basePath string
|
||||||
name string
|
name string
|
||||||
|
|
@ -52,14 +30,11 @@ type boundTool struct {
|
||||||
handler gin.HandlerFunc
|
handler gin.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ RouteGroup = (*ToolBridge)(nil)
|
|
||||||
var _ DescribableGroup = (*ToolBridge)(nil)
|
|
||||||
|
|
||||||
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
|
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
|
||||||
//
|
//
|
||||||
// Example:
|
// bridge := api.NewToolBridge("/tools")
|
||||||
//
|
// bridge.Add(api.ToolDescriptor{Name: "file_read"}, fileReadHandler)
|
||||||
// bridge := api.NewToolBridge("/mcp")
|
// engine.Register(bridge)
|
||||||
func NewToolBridge(basePath string) *ToolBridge {
|
func NewToolBridge(basePath string) *ToolBridge {
|
||||||
return &ToolBridge{
|
return &ToolBridge{
|
||||||
basePath: basePath,
|
basePath: basePath,
|
||||||
|
|
@ -69,70 +44,63 @@ func NewToolBridge(basePath string) *ToolBridge {
|
||||||
|
|
||||||
// Add registers a tool with its HTTP handler.
|
// Add registers a tool with its HTTP handler.
|
||||||
//
|
//
|
||||||
// Example:
|
// bridge.Add(api.ToolDescriptor{Name: "file_read", Group: "files"}, fileReadHandler)
|
||||||
//
|
|
||||||
// bridge.Add(api.ToolDescriptor{Name: "ping", Description: "Ping the service"}, handler)
|
|
||||||
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
|
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
|
||||||
if validator := newToolInputValidator(desc.OutputSchema); validator != nil {
|
|
||||||
handler = wrapToolResponseHandler(handler, validator)
|
|
||||||
}
|
|
||||||
if validator := newToolInputValidator(desc.InputSchema); validator != nil {
|
|
||||||
handler = wrapToolHandler(handler, validator)
|
|
||||||
}
|
|
||||||
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
|
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the bridge identifier.
|
// Name returns the bridge identifier.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// name := bridge.Name()
|
|
||||||
func (b *ToolBridge) Name() string { return b.name }
|
func (b *ToolBridge) Name() string { return b.name }
|
||||||
|
|
||||||
// BasePath returns the URL prefix for all tool endpoints.
|
// BasePath returns the URL prefix for all tool endpoints.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// path := bridge.BasePath()
|
|
||||||
func (b *ToolBridge) BasePath() string { return b.basePath }
|
func (b *ToolBridge) BasePath() string { return b.basePath }
|
||||||
|
|
||||||
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
|
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// bridge.RegisterRoutes(rg)
|
|
||||||
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
for _, t := range b.tools {
|
for _, tool := range b.tools {
|
||||||
rg.POST("/"+t.descriptor.Name, t.handler)
|
rg.POST("/"+tool.descriptor.Name, tool.handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describe returns OpenAPI route descriptions for all registered tools.
|
// Describe returns OpenAPI route descriptions for all registered tools.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// descs := bridge.Describe()
|
|
||||||
func (b *ToolBridge) Describe() []RouteDescription {
|
func (b *ToolBridge) Describe() []RouteDescription {
|
||||||
tools := b.snapshotTools()
|
descs := make([]RouteDescription, 0, len(b.tools))
|
||||||
descs := make([]RouteDescription, 0, len(tools))
|
for _, tool := range b.tools {
|
||||||
for _, tool := range tools {
|
tags := []string{tool.descriptor.Group}
|
||||||
descs = append(descs, describeTool(tool.descriptor, b.name))
|
if tool.descriptor.Group == "" {
|
||||||
|
tags = []string{b.name}
|
||||||
|
}
|
||||||
|
descs = append(descs, RouteDescription{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/" + tool.descriptor.Name,
|
||||||
|
Summary: tool.descriptor.Description,
|
||||||
|
Description: tool.descriptor.Description,
|
||||||
|
Tags: tags,
|
||||||
|
RequestBody: tool.descriptor.InputSchema,
|
||||||
|
Response: tool.descriptor.OutputSchema,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return descs
|
return descs
|
||||||
}
|
}
|
||||||
|
|
||||||
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
|
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for rd := range bridge.DescribeIter() {
|
|
||||||
// _ = rd
|
|
||||||
// }
|
|
||||||
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
||||||
tools := b.snapshotTools()
|
|
||||||
return func(yield func(RouteDescription) bool) {
|
return func(yield func(RouteDescription) bool) {
|
||||||
for _, tool := range tools {
|
for _, tool := range b.tools {
|
||||||
if !yield(describeTool(tool.descriptor, b.name)) {
|
tags := []string{tool.descriptor.Group}
|
||||||
|
if tool.descriptor.Group == "" {
|
||||||
|
tags = []string{b.name}
|
||||||
|
}
|
||||||
|
rd := RouteDescription{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/" + tool.descriptor.Name,
|
||||||
|
Summary: tool.descriptor.Description,
|
||||||
|
Description: tool.descriptor.Description,
|
||||||
|
Tags: tags,
|
||||||
|
RequestBody: tool.descriptor.InputSchema,
|
||||||
|
Response: tool.descriptor.OutputSchema,
|
||||||
|
}
|
||||||
|
if !yield(rd) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,746 +108,21 @@ func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools returns all registered tool descriptors.
|
// Tools returns all registered tool descriptors.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// descs := bridge.Tools()
|
|
||||||
func (b *ToolBridge) Tools() []ToolDescriptor {
|
func (b *ToolBridge) Tools() []ToolDescriptor {
|
||||||
tools := b.snapshotTools()
|
descs := make([]ToolDescriptor, len(b.tools))
|
||||||
descs := make([]ToolDescriptor, len(tools))
|
for i, tool := range b.tools {
|
||||||
for i, t := range tools {
|
descs[i] = tool.descriptor
|
||||||
descs[i] = t.descriptor
|
|
||||||
}
|
}
|
||||||
return descs
|
return descs
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolsIter returns an iterator over all registered tool descriptors.
|
// ToolsIter returns an iterator over all registered tool descriptors.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for desc := range bridge.ToolsIter() {
|
|
||||||
// _ = desc
|
|
||||||
// }
|
|
||||||
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
|
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
|
||||||
tools := b.snapshotTools()
|
|
||||||
return func(yield func(ToolDescriptor) bool) {
|
return func(yield func(ToolDescriptor) bool) {
|
||||||
for _, tool := range tools {
|
for _, tool := range b.tools {
|
||||||
if !yield(tool.descriptor) {
|
if !yield(tool.descriptor) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ToolBridge) snapshotTools() []boundTool {
|
|
||||||
if len(b.tools) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return slices.Clone(b.tools)
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeTool(desc ToolDescriptor, defaultTag string) RouteDescription {
|
|
||||||
tags := cleanTags([]string{desc.Group})
|
|
||||||
if len(tags) == 0 {
|
|
||||||
tags = []string{defaultTag}
|
|
||||||
}
|
|
||||||
return RouteDescription{
|
|
||||||
Method: "POST",
|
|
||||||
Path: "/" + desc.Name,
|
|
||||||
Summary: desc.Description,
|
|
||||||
Description: desc.Description,
|
|
||||||
Tags: tags,
|
|
||||||
RequestBody: desc.InputSchema,
|
|
||||||
Response: desc.OutputSchema,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapToolHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
body, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
|
|
||||||
"invalid_request_body",
|
|
||||||
"Unable to read request body",
|
|
||||||
map[string]any{"error": err.Error()},
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validator.Validate(body); err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
|
|
||||||
"invalid_request_body",
|
|
||||||
"Request body does not match the declared tool schema",
|
|
||||||
map[string]any{"error": err.Error()},
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
|
||||||
handler(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapToolResponseHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
recorder := newToolResponseRecorder(c.Writer)
|
|
||||||
c.Writer = recorder
|
|
||||||
|
|
||||||
handler(c)
|
|
||||||
|
|
||||||
if recorder.Status() >= 200 && recorder.Status() < 300 {
|
|
||||||
if err := validator.ValidateResponse(recorder.body.Bytes()); err != nil {
|
|
||||||
recorder.reset()
|
|
||||||
recorder.writeErrorResponse(http.StatusInternalServerError, FailWithDetails(
|
|
||||||
"invalid_tool_response",
|
|
||||||
"Tool response does not match the declared output schema",
|
|
||||||
map[string]any{"error": err.Error()},
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder.commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type toolInputValidator struct {
|
|
||||||
schema map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func newToolInputValidator(schema map[string]any) *toolInputValidator {
|
|
||||||
if len(schema) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &toolInputValidator{schema: schema}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *toolInputValidator) Validate(body []byte) error {
|
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
|
||||||
return coreerr.E("ToolBridge.Validate", "request body is required", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
dec.UseNumber()
|
|
||||||
|
|
||||||
var payload any
|
|
||||||
if err := dec.Decode(&payload); err != nil {
|
|
||||||
return coreerr.E("ToolBridge.Validate", "invalid JSON", err)
|
|
||||||
}
|
|
||||||
var extra any
|
|
||||||
if err := dec.Decode(&extra); err != io.EOF {
|
|
||||||
return coreerr.E("ToolBridge.Validate", "request body must contain a single JSON value", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return validateSchemaNode(payload, v.schema, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *toolInputValidator) ValidateResponse(body []byte) error {
|
|
||||||
if len(v.schema) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var envelope map[string]any
|
|
||||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
||||||
return coreerr.E("ToolBridge.ValidateResponse", "invalid JSON response", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
success, _ := envelope["success"].(bool)
|
|
||||||
if !success {
|
|
||||||
return coreerr.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := envelope["data"]
|
|
||||||
if !ok {
|
|
||||||
return coreerr.E("ToolBridge.ValidateResponse", "response is missing data", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("ToolBridge.ValidateResponse", "encode response data", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload any
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(encoded))
|
|
||||||
dec.UseNumber()
|
|
||||||
if err := dec.Decode(&payload); err != nil {
|
|
||||||
return coreerr.E("ToolBridge.ValidateResponse", "decode response data", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return validateSchemaNode(payload, v.schema, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSchemaNode(value any, schema map[string]any, path string) error {
|
|
||||||
if len(schema) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
schemaType, _ := schema["type"].(string)
|
|
||||||
if schemaType != "" {
|
|
||||||
switch schemaType {
|
|
||||||
case "object":
|
|
||||||
obj, ok := value.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return typeError(path, "object", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range stringList(schema["required"]) {
|
|
||||||
if _, ok := obj[name]; !ok {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s is missing required field %q", displayPath(path), name), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, rawChild := range schemaMap(schema["properties"]) {
|
|
||||||
childSchema, ok := rawChild.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childValue, ok := obj[name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := validateSchemaNode(childValue, childSchema, joinPath(path, name)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if additionalProperties, ok := schema["additionalProperties"].(bool); ok && !additionalProperties {
|
|
||||||
properties := schemaMap(schema["properties"])
|
|
||||||
for name := range obj {
|
|
||||||
if properties != nil {
|
|
||||||
if _, ok := properties[name]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s contains unknown field %q", displayPath(path), name), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := validateObjectConstraints(obj, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case "array":
|
|
||||||
arr, ok := value.([]any)
|
|
||||||
if !ok {
|
|
||||||
return typeError(path, "array", value)
|
|
||||||
}
|
|
||||||
if items := schemaMap(schema["items"]); len(items) > 0 {
|
|
||||||
for i, item := range arr {
|
|
||||||
if err := validateSchemaNode(item, items, joinPath(path, strconv.Itoa(i))); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := validateArrayConstraints(arr, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case "string":
|
|
||||||
str, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return typeError(path, "string", value)
|
|
||||||
}
|
|
||||||
if err := validateStringConstraints(str, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case "boolean":
|
|
||||||
if _, ok := value.(bool); !ok {
|
|
||||||
return typeError(path, "boolean", value)
|
|
||||||
}
|
|
||||||
case "integer":
|
|
||||||
if !isIntegerValue(value) {
|
|
||||||
return typeError(path, "integer", value)
|
|
||||||
}
|
|
||||||
if err := validateNumericConstraints(value, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case "number":
|
|
||||||
if !isNumberValue(value) {
|
|
||||||
return typeError(path, "number", value)
|
|
||||||
}
|
|
||||||
if err := validateNumericConstraints(value, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemaType == "" && (len(schemaMap(schema["properties"])) > 0 || schema["required"] != nil || schema["additionalProperties"] != nil) {
|
|
||||||
props := schemaMap(schema["properties"])
|
|
||||||
return validateSchemaNode(value, map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": props,
|
|
||||||
"required": schema["required"],
|
|
||||||
"additionalProperties": schema["additionalProperties"],
|
|
||||||
}, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rawEnum, ok := schema["enum"]; ok {
|
|
||||||
if !enumContains(value, rawEnum) {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateSchemaCombinators(value, schema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSchemaCombinators(value any, schema map[string]any, path string) error {
|
|
||||||
if subschemas := schemaObjects(schema["allOf"]); len(subschemas) > 0 {
|
|
||||||
for _, subschema := range subschemas {
|
|
||||||
if err := validateSchemaNode(value, subschema, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subschemas := schemaObjects(schema["anyOf"]); len(subschemas) > 0 {
|
|
||||||
for _, subschema := range subschemas {
|
|
||||||
if err := validateSchemaNode(value, subschema, path); err == nil {
|
|
||||||
goto anyOfMatched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
anyOfMatched:
|
|
||||||
if subschemas := schemaObjects(schema["oneOf"]); len(subschemas) > 0 {
|
|
||||||
matches := 0
|
|
||||||
for _, subschema := range subschemas {
|
|
||||||
if err := validateSchemaNode(value, subschema, path); err == nil {
|
|
||||||
matches++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matches != 1 {
|
|
||||||
if matches == 0 {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil)
|
|
||||||
}
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subschema, ok := schema["not"].(map[string]any); ok && subschema != nil {
|
|
||||||
if err := validateSchemaNode(value, subschema, path); err == nil {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateStringConstraints(value string, schema map[string]any, path string) error {
|
|
||||||
length := utf8.RuneCountInString(value)
|
|
||||||
if minLength, ok := schemaInt(schema["minLength"]); ok && length < minLength {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil)
|
|
||||||
}
|
|
||||||
if maxLength, ok := schemaInt(schema["maxLength"]); ok && length > maxLength {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil)
|
|
||||||
}
|
|
||||||
if pattern, ok := schema["pattern"].(string); ok && pattern != "" {
|
|
||||||
re, err := regexp.Compile(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err)
|
|
||||||
}
|
|
||||||
if !re.MatchString(value) {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateNumericConstraints(value any, schema map[string]any, path string) error {
|
|
||||||
if minimum, ok := schemaFloat(schema["minimum"]); ok && numericLessThan(value, minimum) {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil)
|
|
||||||
}
|
|
||||||
if maximum, ok := schemaFloat(schema["maximum"]); ok && numericGreaterThan(value, maximum) {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateArrayConstraints(value []any, schema map[string]any, path string) error {
|
|
||||||
if minItems, ok := schemaInt(schema["minItems"]); ok && len(value) < minItems {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil)
|
|
||||||
}
|
|
||||||
if maxItems, ok := schemaInt(schema["maxItems"]); ok && len(value) > maxItems {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateObjectConstraints(value map[string]any, schema map[string]any, path string) error {
|
|
||||||
if minProps, ok := schemaInt(schema["minProperties"]); ok && len(value) < minProps {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil)
|
|
||||||
}
|
|
||||||
if maxProps, ok := schemaInt(schema["maxProperties"]); ok && len(value) > maxProps {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func schemaInt(value any) (int, bool) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case int:
|
|
||||||
return v, true
|
|
||||||
case int8:
|
|
||||||
return int(v), true
|
|
||||||
case int16:
|
|
||||||
return int(v), true
|
|
||||||
case int32:
|
|
||||||
return int(v), true
|
|
||||||
case int64:
|
|
||||||
return int(v), true
|
|
||||||
case uint:
|
|
||||||
return int(v), true
|
|
||||||
case uint8:
|
|
||||||
return int(v), true
|
|
||||||
case uint16:
|
|
||||||
return int(v), true
|
|
||||||
case uint32:
|
|
||||||
return int(v), true
|
|
||||||
case uint64:
|
|
||||||
return int(v), true
|
|
||||||
case float64:
|
|
||||||
if v == float64(int(v)) {
|
|
||||||
return int(v), true
|
|
||||||
}
|
|
||||||
case json.Number:
|
|
||||||
if n, err := v.Int64(); err == nil {
|
|
||||||
return int(n), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func schemaFloat(value any) (float64, bool) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case float64:
|
|
||||||
return v, true
|
|
||||||
case float32:
|
|
||||||
return float64(v), true
|
|
||||||
case int:
|
|
||||||
return float64(v), true
|
|
||||||
case int8:
|
|
||||||
return float64(v), true
|
|
||||||
case int16:
|
|
||||||
return float64(v), true
|
|
||||||
case int32:
|
|
||||||
return float64(v), true
|
|
||||||
case int64:
|
|
||||||
return float64(v), true
|
|
||||||
case uint:
|
|
||||||
return float64(v), true
|
|
||||||
case uint8:
|
|
||||||
return float64(v), true
|
|
||||||
case uint16:
|
|
||||||
return float64(v), true
|
|
||||||
case uint32:
|
|
||||||
return float64(v), true
|
|
||||||
case uint64:
|
|
||||||
return float64(v), true
|
|
||||||
case json.Number:
|
|
||||||
if n, err := v.Float64(); err == nil {
|
|
||||||
return n, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func numericLessThan(value any, limit float64) bool {
|
|
||||||
if n, ok := numericValue(value); ok {
|
|
||||||
return n < limit
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func numericGreaterThan(value any, limit float64) bool {
|
|
||||||
if n, ok := numericValue(value); ok {
|
|
||||||
return n > limit
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type toolResponseRecorder struct {
|
|
||||||
gin.ResponseWriter
|
|
||||||
headers http.Header
|
|
||||||
body bytes.Buffer
|
|
||||||
status int
|
|
||||||
wroteHeader bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newToolResponseRecorder(w gin.ResponseWriter) *toolResponseRecorder {
|
|
||||||
headers := make(http.Header)
|
|
||||||
for k, vals := range w.Header() {
|
|
||||||
headers[k] = append([]string(nil), vals...)
|
|
||||||
}
|
|
||||||
return &toolResponseRecorder{
|
|
||||||
ResponseWriter: w,
|
|
||||||
headers: headers,
|
|
||||||
status: http.StatusOK,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Header() http.Header {
|
|
||||||
return w.headers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) WriteHeader(code int) {
|
|
||||||
w.status = code
|
|
||||||
w.wroteHeader = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) WriteHeaderNow() {
|
|
||||||
w.wroteHeader = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Write(data []byte) (int, error) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.body.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) WriteString(s string) (int, error) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.body.WriteString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Flush() {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Status() int {
|
|
||||||
if w.wroteHeader {
|
|
||||||
return w.status
|
|
||||||
}
|
|
||||||
return http.StatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Size() int {
|
|
||||||
return w.body.Len()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Written() bool {
|
|
||||||
return w.wroteHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
||||||
return nil, nil, coreerr.E("ToolBridge.ResponseRecorder", "response hijacking is not supported by ToolBridge output validation", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) commit() {
|
|
||||||
for k := range w.ResponseWriter.Header() {
|
|
||||||
w.ResponseWriter.Header().Del(k)
|
|
||||||
}
|
|
||||||
for k, vals := range w.headers {
|
|
||||||
for _, v := range vals {
|
|
||||||
w.ResponseWriter.Header().Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.ResponseWriter.WriteHeader(w.Status())
|
|
||||||
_, _ = w.ResponseWriter.Write(w.body.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) reset() {
|
|
||||||
w.headers = make(http.Header)
|
|
||||||
w.body.Reset()
|
|
||||||
w.status = http.StatusInternalServerError
|
|
||||||
w.wroteHeader = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]) {
|
|
||||||
data, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w.ResponseWriter, "internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.ResponseWriter.Header().Set("Content-Type", "application/json")
|
|
||||||
w.ResponseWriter.WriteHeader(status)
|
|
||||||
_, _ = w.ResponseWriter.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func typeError(path, want string, value any) error {
|
|
||||||
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayPath(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return "request body"
|
|
||||||
}
|
|
||||||
return "request body." + path
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinPath(parent, child string) string {
|
|
||||||
if parent == "" {
|
|
||||||
return child
|
|
||||||
}
|
|
||||||
return parent + "." + child
|
|
||||||
}
|
|
||||||
|
|
||||||
func schemaMap(value any) map[string]any {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m, _ := value.(map[string]any)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func schemaObjects(value any) []map[string]any {
|
|
||||||
switch raw := value.(type) {
|
|
||||||
case []any:
|
|
||||||
out := make([]map[string]any, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
if schema := schemaMap(item); schema != nil {
|
|
||||||
out = append(out, schema)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []map[string]any:
|
|
||||||
return append([]map[string]any(nil), raw...)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringList(value any) []string {
|
|
||||||
switch raw := value.(type) {
|
|
||||||
case []any:
|
|
||||||
out := make([]string, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
name, ok := item.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
return append([]string(nil), raw...)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isIntegerValue(value any) bool {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case json.Number:
|
|
||||||
_, err := v.Int64()
|
|
||||||
return err == nil
|
|
||||||
case float64:
|
|
||||||
return v == float64(int64(v))
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNumberValue(value any) bool {
|
|
||||||
switch value.(type) {
|
|
||||||
case json.Number, float64:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enumContains(value any, rawEnum any) bool {
|
|
||||||
items := enumValues(rawEnum)
|
|
||||||
for _, candidate := range items {
|
|
||||||
if valuesEqual(value, candidate) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func enumValues(rawEnum any) []any {
|
|
||||||
switch values := rawEnum.(type) {
|
|
||||||
case []any:
|
|
||||||
out := make([]any, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
out := make([]any, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func valuesEqual(left, right any) bool {
|
|
||||||
if isNumericValue(left) && isNumericValue(right) {
|
|
||||||
lv, lok := numericValue(left)
|
|
||||||
rv, rok := numericValue(right)
|
|
||||||
return lok && rok && lv == rv
|
|
||||||
}
|
|
||||||
return reflect.DeepEqual(left, right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNumericValue(value any) bool {
|
|
||||||
switch value.(type) {
|
|
||||||
case json.Number, float64, float32, int, int8, int16, int32, int64,
|
|
||||||
uint, uint8, uint16, uint32, uint64:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func numericValue(value any) (float64, bool) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case json.Number:
|
|
||||||
n, err := v.Float64()
|
|
||||||
return n, err == nil
|
|
||||||
case float64:
|
|
||||||
return v, true
|
|
||||||
case float32:
|
|
||||||
return float64(v), true
|
|
||||||
case int:
|
|
||||||
return float64(v), true
|
|
||||||
case int8:
|
|
||||||
return float64(v), true
|
|
||||||
case int16:
|
|
||||||
return float64(v), true
|
|
||||||
case int32:
|
|
||||||
return float64(v), true
|
|
||||||
case int64:
|
|
||||||
return float64(v), true
|
|
||||||
case uint:
|
|
||||||
return float64(v), true
|
|
||||||
case uint8:
|
|
||||||
return float64(v), true
|
|
||||||
case uint16:
|
|
||||||
return float64(v), true
|
|
||||||
case uint32:
|
|
||||||
return float64(v), true
|
|
||||||
case uint64:
|
|
||||||
return float64(v), true
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeJSONValue(value any) string {
|
|
||||||
switch value.(type) {
|
|
||||||
case nil:
|
|
||||||
return "null"
|
|
||||||
case string:
|
|
||||||
return "string"
|
|
||||||
case bool:
|
|
||||||
return "boolean"
|
|
||||||
case json.Number, float64:
|
|
||||||
return "number"
|
|
||||||
case map[string]any:
|
|
||||||
return "object"
|
|
||||||
case []any:
|
|
||||||
return "array"
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%T", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
534
bridge_test.go
534
bridge_test.go
|
|
@ -3,7 +3,6 @@
|
||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -154,522 +153,6 @@ func TestToolBridge_Good_Describe(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolBridge_Good_DescribeTrimsBlankGroup(t *testing.T) {
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "file_read",
|
|
||||||
Description: "Read a file from disk",
|
|
||||||
Group: " ",
|
|
||||||
}, func(c *gin.Context) {})
|
|
||||||
|
|
||||||
descs := bridge.Describe()
|
|
||||||
if len(descs) != 1 {
|
|
||||||
t.Fatalf("expected 1 description, got %d", len(descs))
|
|
||||||
}
|
|
||||||
if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "tools" {
|
|
||||||
t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[0].Tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_ValidatesRequestBody(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "file_read",
|
|
||||||
Description: "Read a file from disk",
|
|
||||||
Group: "files",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []any{"path"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil {
|
|
||||||
t.Fatalf("handler could not read validated body: %v", err)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, api.OK(payload["path"]))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":"/tmp/file.txt"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[string]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Data != "/tmp/file.txt" {
|
|
||||||
t.Fatalf("expected validated payload to reach handler, got %q", resp.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_ValidatesResponseBody(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "file_read",
|
|
||||||
Description: "Read a file from disk",
|
|
||||||
Group: "files",
|
|
||||||
OutputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []any{"path"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK(map[string]any{"path": "/tmp/file.txt"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[map[string]any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if !resp.Success {
|
|
||||||
t.Fatal("expected Success=true")
|
|
||||||
}
|
|
||||||
if resp.Data["path"] != "/tmp/file.txt" {
|
|
||||||
t.Fatalf("expected validated response data to reach client, got %v", resp.Data["path"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_InvalidResponseBody(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "file_read",
|
|
||||||
Description: "Read a file from disk",
|
|
||||||
Group: "files",
|
|
||||||
OutputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []any{"path"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK(map[string]any{"path": 123}))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Fatalf("expected 500, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_tool_response" {
|
|
||||||
t.Fatalf("expected invalid_tool_response error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_InvalidRequestBody(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "file_read",
|
|
||||||
Description: "Read a file from disk",
|
|
||||||
Group: "files",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []any{"path"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("should not run"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":123}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
|
|
||||||
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_ValidatesEnumValues(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "publish_item",
|
|
||||||
Description: "Publish an item",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"status": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []any{"draft", "published"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"status"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("published"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_RejectsInvalidEnumValues(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "publish_item",
|
|
||||||
Description: "Publish an item",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"status": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []any{"draft", "published"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"status"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("published"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"archived"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
|
|
||||||
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_ValidatesSchemaCombinators(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "route_choice",
|
|
||||||
Description: "Choose a route",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"choice": map[string]any{
|
|
||||||
"oneOf": []any{
|
|
||||||
map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"allOf": []any{
|
|
||||||
map[string]any{"minLength": 2},
|
|
||||||
map[string]any{"pattern": "^[A-Z]+$"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^A",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"choice"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("accepted"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"BC"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "route_choice",
|
|
||||||
Description: "Choose a route",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"choice": map[string]any{
|
|
||||||
"oneOf": []any{
|
|
||||||
map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"allOf": []any{
|
|
||||||
map[string]any{"minLength": 1},
|
|
||||||
map[string]any{"pattern": "^[A-Z]+$"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^A",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"choice"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("accepted"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"A"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
|
|
||||||
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_RejectsAdditionalProperties(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "publish_item",
|
|
||||||
Description: "Publish an item",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"status": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
"required": []any{"status"},
|
|
||||||
"additionalProperties": false,
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("published"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published","unexpected":true}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
|
|
||||||
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_EnforcesStringConstraints(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "publish_code",
|
|
||||||
Description: "Publish a code",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"code": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 3,
|
|
||||||
"maxLength": 5,
|
|
||||||
"pattern": "^[A-Z]+$",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"code"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("accepted"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_code", bytes.NewBufferString(`{"code":"ABC"}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Bad_RejectsNumericAndCollectionConstraints(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
engine := gin.New()
|
|
||||||
|
|
||||||
bridge := api.NewToolBridge("/tools")
|
|
||||||
bridge.Add(api.ToolDescriptor{
|
|
||||||
Name: "quota_check",
|
|
||||||
Description: "Check quotas",
|
|
||||||
Group: "items",
|
|
||||||
InputSchema: map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"count": map[string]any{
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 3,
|
|
||||||
},
|
|
||||||
"labels": map[string]any{
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 2,
|
|
||||||
"maxItems": 4,
|
|
||||||
"items": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"payload": map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"minProperties": 1,
|
|
||||||
"maxProperties": 2,
|
|
||||||
"additionalProperties": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []any{"count", "labels", "payload"},
|
|
||||||
},
|
|
||||||
}, func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("accepted"))
|
|
||||||
})
|
|
||||||
|
|
||||||
rg := engine.Group(bridge.BasePath())
|
|
||||||
bridge.RegisterRoutes(rg)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "/tools/quota_check", bytes.NewBufferString(`{"count":0,"labels":["one"],"payload":{}}`))
|
|
||||||
engine.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400 for numeric/collection constraint failure, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
|
|
||||||
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Good_ToolsAccessor(t *testing.T) {
|
func TestToolBridge_Good_ToolsAccessor(t *testing.T) {
|
||||||
bridge := api.NewToolBridge("/tools")
|
bridge := api.NewToolBridge("/tools")
|
||||||
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})
|
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})
|
||||||
|
|
@ -749,3 +232,20 @@ func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) {
|
||||||
t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
|
t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolBridge_Ugly_NilHandlerDoesNotPanic(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("Add with nil handler panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bridge := api.NewToolBridge("/tools")
|
||||||
|
// Adding a tool with a nil handler should not panic on Add itself.
|
||||||
|
bridge.Add(api.ToolDescriptor{Name: "noop", Group: "test"}, nil)
|
||||||
|
|
||||||
|
tools := bridge.Tools()
|
||||||
|
if len(tools) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -47,7 +47,7 @@ func newBrotliHandler(level int) *brotliHandler {
|
||||||
|
|
||||||
// Handle is the Gin middleware function that compresses responses with Brotli.
|
// Handle is the Gin middleware function that compresses responses with Brotli.
|
||||||
func (h *brotliHandler) Handle(c *gin.Context) {
|
func (h *brotliHandler) Handle(c *gin.Context) {
|
||||||
if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
|
if !core.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,27 @@ func TestWithBrotli_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
t.Fatal("expected X-Request-ID header from WithRequestID")
|
t.Fatal("expected X-Request-ID header from WithRequestID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithBrotli_Ugly_InvalidLevelClampsToDefault(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("WithBrotli with invalid level panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
// A level out of range should silently clamp to default, not panic.
|
||||||
|
e, err := api.New(api.WithBrotli(999))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
request.Header.Set("Accept-Encoding", "br")
|
||||||
|
e.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
153
cache.go
153
cache.go
|
|
@ -4,10 +4,8 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"container/list"
|
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -19,57 +17,34 @@ type cacheEntry struct {
|
||||||
status int
|
status int
|
||||||
headers http.Header
|
headers http.Header
|
||||||
body []byte
|
body []byte
|
||||||
size int
|
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
|
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
|
||||||
type cacheStore struct {
|
type cacheStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]*cacheEntry
|
entries map[string]*cacheEntry
|
||||||
order *list.List
|
|
||||||
index map[string]*list.Element
|
|
||||||
maxEntries int
|
|
||||||
maxBytes int
|
|
||||||
currentBytes int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCacheStore creates an empty cache store.
|
// newCacheStore creates an empty cache store.
|
||||||
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
|
func newCacheStore() *cacheStore {
|
||||||
return &cacheStore{
|
return &cacheStore{
|
||||||
entries: make(map[string]*cacheEntry),
|
entries: make(map[string]*cacheEntry),
|
||||||
order: list.New(),
|
|
||||||
index: make(map[string]*list.Element),
|
|
||||||
maxEntries: maxEntries,
|
|
||||||
maxBytes: maxBytes,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get retrieves a non-expired entry for the given key.
|
// get retrieves a non-expired entry for the given key.
|
||||||
// Returns nil if the key is missing or expired.
|
// Returns nil if the key is missing or expired.
|
||||||
func (s *cacheStore) get(key string) *cacheEntry {
|
func (s *cacheStore) get(key string) *cacheEntry {
|
||||||
s.mu.Lock()
|
s.mu.RLock()
|
||||||
entry, ok := s.entries[key]
|
entry, ok := s.entries[key]
|
||||||
if ok {
|
s.mu.RUnlock()
|
||||||
if elem, exists := s.index[key]; exists {
|
|
||||||
s.order.MoveToFront(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if time.Now().After(entry.expires) {
|
if time.Now().After(entry.expires) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if elem, exists := s.index[key]; exists {
|
|
||||||
s.order.Remove(elem)
|
|
||||||
delete(s.index, key)
|
|
||||||
}
|
|
||||||
s.currentBytes -= entry.size
|
|
||||||
if s.currentBytes < 0 {
|
|
||||||
s.currentBytes = 0
|
|
||||||
}
|
|
||||||
delete(s.entries, key)
|
delete(s.entries, key)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -80,81 +55,10 @@ func (s *cacheStore) get(key string) *cacheEntry {
|
||||||
// set stores a cache entry with the given TTL.
|
// set stores a cache entry with the given TTL.
|
||||||
func (s *cacheStore) set(key string, entry *cacheEntry) {
|
func (s *cacheStore) set(key string, entry *cacheEntry) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if entry.size <= 0 {
|
|
||||||
entry.size = cacheEntrySize(entry.headers, entry.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if elem, ok := s.index[key]; ok {
|
|
||||||
if existing, exists := s.entries[key]; exists {
|
|
||||||
s.currentBytes -= existing.size
|
|
||||||
if s.currentBytes < 0 {
|
|
||||||
s.currentBytes = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.order.MoveToFront(elem)
|
|
||||||
s.entries[key] = entry
|
|
||||||
s.currentBytes += entry.size
|
|
||||||
s.evictBySizeLocked()
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.maxBytes > 0 && entry.size > s.maxBytes {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (s.maxEntries > 0 && len(s.entries) >= s.maxEntries) || s.wouldExceedBytesLocked(entry.size) {
|
|
||||||
if !s.evictOldestLocked() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.maxBytes > 0 && s.wouldExceedBytesLocked(entry.size) {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.entries[key] = entry
|
s.entries[key] = entry
|
||||||
elem := s.order.PushFront(key)
|
|
||||||
s.index[key] = elem
|
|
||||||
s.currentBytes += entry.size
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *cacheStore) wouldExceedBytesLocked(nextSize int) bool {
|
|
||||||
if s.maxBytes <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return s.currentBytes+nextSize > s.maxBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *cacheStore) evictBySizeLocked() {
|
|
||||||
for s.maxBytes > 0 && s.currentBytes > s.maxBytes {
|
|
||||||
if !s.evictOldestLocked() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *cacheStore) evictOldestLocked() bool {
|
|
||||||
back := s.order.Back()
|
|
||||||
if back == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
oldKey := back.Value.(string)
|
|
||||||
if existing, ok := s.entries[oldKey]; ok {
|
|
||||||
s.currentBytes -= existing.size
|
|
||||||
if s.currentBytes < 0 {
|
|
||||||
s.currentBytes = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(s.entries, oldKey)
|
|
||||||
delete(s.index, oldKey)
|
|
||||||
s.order.Remove(back)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheWriter intercepts writes to capture the response body and status.
|
// cacheWriter intercepts writes to capture the response body and status.
|
||||||
type cacheWriter struct {
|
type cacheWriter struct {
|
||||||
gin.ResponseWriter
|
gin.ResponseWriter
|
||||||
|
|
@ -185,31 +89,14 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
||||||
|
|
||||||
// Serve from cache if a valid entry exists.
|
// Serve from cache if a valid entry exists.
|
||||||
if entry := store.get(key); entry != nil {
|
if entry := store.get(key); entry != nil {
|
||||||
body := entry.body
|
for headerName, headerValues := range entry.headers {
|
||||||
if meta := GetRequestMeta(c); meta != nil {
|
for _, headerValue := range headerValues {
|
||||||
body = refreshCachedResponseMeta(entry.body, meta)
|
c.Writer.Header().Set(headerName, headerValue)
|
||||||
}
|
|
||||||
|
|
||||||
for k, vals := range entry.headers {
|
|
||||||
if http.CanonicalHeaderKey(k) == "X-Request-ID" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if http.CanonicalHeaderKey(k) == "Content-Length" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, v := range vals {
|
|
||||||
c.Writer.Header().Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if requestID := GetRequestID(c); requestID != "" {
|
|
||||||
c.Writer.Header().Set("X-Request-ID", requestID)
|
|
||||||
} else if requestID := c.GetHeader("X-Request-ID"); requestID != "" {
|
|
||||||
c.Writer.Header().Set("X-Request-ID", requestID)
|
|
||||||
}
|
}
|
||||||
c.Writer.Header().Set("X-Cache", "HIT")
|
c.Writer.Header().Set("X-Cache", "HIT")
|
||||||
c.Writer.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
||||||
c.Writer.WriteHeader(entry.status)
|
c.Writer.WriteHeader(entry.status)
|
||||||
_, _ = c.Writer.Write(body)
|
_, _ = c.Writer.Write(entry.body)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -232,28 +119,8 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
||||||
status: status,
|
status: status,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: cw.body.Bytes(),
|
body: cw.body.Bytes(),
|
||||||
size: cacheEntrySize(headers, cw.body.Bytes()),
|
|
||||||
expires: time.Now().Add(ttl),
|
expires: time.Now().Add(ttl),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshCachedResponseMeta updates the meta envelope in a cached JSON body so
|
|
||||||
// request-scoped metadata reflects the current request instead of the cache fill.
|
|
||||||
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
|
|
||||||
// returned unchanged.
|
|
||||||
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
|
|
||||||
return refreshResponseMetaBody(body, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheEntrySize(headers http.Header, body []byte) int {
|
|
||||||
size := len(body)
|
|
||||||
for key, vals := range headers {
|
|
||||||
size += len(key)
|
|
||||||
for _, val := range vals {
|
|
||||||
size += len(val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// CacheConfig captures the configured response cache settings for an Engine.
|
|
||||||
//
|
|
||||||
// It is intentionally small and serialisable so callers can inspect the active
|
|
||||||
// cache policy without needing to rebuild middleware state.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := api.CacheConfig{Enabled: true, TTL: 5 * time.Minute}
|
|
||||||
type CacheConfig struct {
|
|
||||||
Enabled bool
|
|
||||||
TTL time.Duration
|
|
||||||
MaxEntries int
|
|
||||||
MaxBytes int
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheConfig returns the currently configured response cache settings for the engine.
|
|
||||||
//
|
|
||||||
// The result snapshots the Engine state at call time.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.CacheConfig()
|
|
||||||
func (e *Engine) CacheConfig() CacheConfig {
|
|
||||||
if e == nil {
|
|
||||||
return CacheConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := CacheConfig{
|
|
||||||
TTL: e.cacheTTL,
|
|
||||||
MaxEntries: e.cacheMaxEntries,
|
|
||||||
MaxBytes: e.cacheMaxBytes,
|
|
||||||
}
|
|
||||||
if e.cacheTTL > 0 {
|
|
||||||
cfg.Enabled = true
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
314
cache_test.go
314
cache_test.go
|
|
@ -40,23 +40,6 @@ func (g *cacheCounterGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheSizedGroup struct {
|
|
||||||
counter atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *cacheSizedGroup) Name() string { return "cache-sized" }
|
|
||||||
func (g *cacheSizedGroup) BasePath() string { return "/cache" }
|
|
||||||
func (g *cacheSizedGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/small", func(c *gin.Context) {
|
|
||||||
n := g.counter.Add(1)
|
|
||||||
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("small-%d-%s", n, strings.Repeat("a", 96))))
|
|
||||||
})
|
|
||||||
rg.GET("/large", func(c *gin.Context) {
|
|
||||||
n := g.counter.Add(1)
|
|
||||||
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("large-%d-%s", n, strings.Repeat("b", 96))))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WithCache ───────────────────────────────────────────────────────────
|
// ── WithCache ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
|
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
|
||||||
|
|
@ -106,36 +89,6 @@ func TestWithCache_Good_CachesGETResponse(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithCacheLimits_Good_CachesGETResponse(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
grp := &cacheCounterGroup{}
|
|
||||||
e, _ := api.New(api.WithCacheLimits(5*time.Second, 1, 0))
|
|
||||||
e.Register(grp)
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := w2.Header().Get("X-Cache"); got != "HIT" {
|
|
||||||
t.Fatalf("expected X-Cache=HIT, got %q", got)
|
|
||||||
}
|
|
||||||
if grp.counter.Load() != 1 {
|
|
||||||
t.Fatalf("expected counter=1 (cached), got %d", grp.counter.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Good_POSTNotCached(t *testing.T) {
|
func TestWithCache_Good_POSTNotCached(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
grp := &cacheCounterGroup{}
|
grp := &cacheCounterGroup{}
|
||||||
|
|
@ -261,189 +214,6 @@ func TestWithCache_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithCache_Good_PreservesCurrentRequestIDOnHit(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
grp := &cacheCounterGroup{}
|
|
||||||
e, _ := api.New(
|
|
||||||
api.WithRequestID(),
|
|
||||||
api.WithCache(5*time.Second),
|
|
||||||
)
|
|
||||||
e.Register(grp)
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
req1.Header.Set("X-Request-ID", "first-request-id")
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
if got := w1.Header().Get("X-Request-ID"); got != "first-request-id" {
|
|
||||||
t.Fatalf("expected first response request ID %q, got %q", "first-request-id", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
req2.Header.Set("X-Request-ID", "second-request-id")
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := w2.Header().Get("X-Request-ID"); got != "second-request-id" {
|
|
||||||
t.Fatalf("expected cached response to preserve current request ID %q, got %q", "second-request-id", got)
|
|
||||||
}
|
|
||||||
if got := w2.Header().Get("X-Cache"); got != "HIT" {
|
|
||||||
t.Fatalf("expected X-Cache=HIT, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp2 api.Response[string]
|
|
||||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp2.Data != "call-1" {
|
|
||||||
t.Fatalf("expected cached response data %q, got %q", "call-1", resp2.Data)
|
|
||||||
}
|
|
||||||
if resp2.Meta == nil {
|
|
||||||
t.Fatal("expected cached response meta to be attached")
|
|
||||||
}
|
|
||||||
if resp2.Meta.RequestID != "second-request-id" {
|
|
||||||
t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp2.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected cached response duration to be refreshed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(
|
|
||||||
api.WithRequestID(),
|
|
||||||
api.WithCache(5*time.Second),
|
|
||||||
)
|
|
||||||
e.Register(requestMetaTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
|
|
||||||
req1.Header.Set("X-Request-ID", "first-request-id")
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp1 api.Response[string]
|
|
||||||
if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp1.Meta == nil {
|
|
||||||
t.Fatal("expected meta on first response")
|
|
||||||
}
|
|
||||||
if resp1.Meta.RequestID != "first-request-id" {
|
|
||||||
t.Fatalf("expected first response request_id=%q, got %q", "first-request-id", resp1.Meta.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
|
|
||||||
req2.Header.Set("X-Request-ID", "second-request-id")
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp2 api.Response[string]
|
|
||||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp2.Meta == nil {
|
|
||||||
t.Fatal("expected meta on cached response")
|
|
||||||
}
|
|
||||||
if resp2.Meta.RequestID != "second-request-id" {
|
|
||||||
t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp2.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected cached response duration to be refreshed")
|
|
||||||
}
|
|
||||||
if resp2.Meta.Page != 1 || resp2.Meta.PerPage != 25 || resp2.Meta.Total != 100 {
|
|
||||||
t.Fatalf("expected pagination metadata to remain intact, got %+v", resp2.Meta)
|
|
||||||
}
|
|
||||||
if got := w2.Header().Get("X-Request-ID"); got != "second-request-id" {
|
|
||||||
t.Fatalf("expected response header X-Request-ID=%q, got %q", "second-request-id", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type cacheHeaderGroup struct{}
|
|
||||||
|
|
||||||
func (cacheHeaderGroup) Name() string { return "cache-headers" }
|
|
||||||
func (cacheHeaderGroup) BasePath() string { return "/cache" }
|
|
||||||
func (cacheHeaderGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/multi", func(c *gin.Context) {
|
|
||||||
c.Writer.Header().Add("Link", "</next?page=2>; rel=\"next\"")
|
|
||||||
c.Writer.Header().Add("Link", "</prev?page=0>; rel=\"prev\"")
|
|
||||||
c.JSON(http.StatusOK, api.OK("cached"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Good_PreservesMultiValueHeadersOnHit(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithCache(5 * time.Second))
|
|
||||||
e.Register(cacheHeaderGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/multi", nil)
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/multi", nil)
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 on cache hit, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
linkHeaders := w2.Header().Values("Link")
|
|
||||||
if len(linkHeaders) != 2 {
|
|
||||||
t.Fatalf("expected 2 Link headers on cache hit, got %v", linkHeaders)
|
|
||||||
}
|
|
||||||
if linkHeaders[0] != "</next?page=2>; rel=\"next\"" {
|
|
||||||
t.Fatalf("expected first Link header to be preserved, got %q", linkHeaders[0])
|
|
||||||
}
|
|
||||||
if linkHeaders[1] != "</prev?page=0>; rel=\"prev\"" {
|
|
||||||
t.Fatalf("expected second Link header to be preserved, got %q", linkHeaders[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Ugly_NonPositiveTTLDisablesMiddleware(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
grp := &cacheCounterGroup{}
|
|
||||||
e, _ := api.New(api.WithCache(0))
|
|
||||||
e.Register(grp)
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected request %d to succeed with disabled cache, got %d", i+1, w.Code)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("X-Cache"); got != "" {
|
|
||||||
t.Fatalf("expected no X-Cache header with disabled cache, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if grp.counter.Load() != 2 {
|
|
||||||
t.Fatalf("expected counter=2 with disabled cache, got %d", grp.counter.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
grp := &cacheCounterGroup{}
|
grp := &cacheCounterGroup{}
|
||||||
|
|
@ -481,74 +251,30 @@ func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithCache_Good_EvictsWhenCapacityReached(t *testing.T) {
|
func TestWithCache_Ugly_ConcurrentGetsDontDeadlock(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
grp := &cacheCounterGroup{}
|
grp := &cacheCounterGroup{}
|
||||||
e, _ := api.New(api.WithCache(5*time.Second, 1))
|
engine, _ := api.New(api.WithCache(50 * time.Millisecond))
|
||||||
e.Register(grp)
|
engine.Register(grp)
|
||||||
|
handler := engine.Handler()
|
||||||
|
|
||||||
h := e.Handler()
|
// Fire many concurrent GET requests; none should deadlock.
|
||||||
|
done := make(chan struct{})
|
||||||
w1 := httptest.NewRecorder()
|
for requestIndex := 0; requestIndex < 20; requestIndex++ {
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
go func() {
|
||||||
h.ServeHTTP(w1, req1)
|
recorder := httptest.NewRecorder()
|
||||||
if !strings.Contains(w1.Body.String(), "call-1") {
|
request, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
||||||
t.Fatalf("expected first response to contain %q, got %q", "call-1", w1.Body.String())
|
handler.ServeHTTP(recorder, request)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
for requestIndex := 0; requestIndex < 20; requestIndex++ {
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil)
|
select {
|
||||||
h.ServeHTTP(w2, req2)
|
case <-done:
|
||||||
if !strings.Contains(w2.Body.String(), "other-2") {
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatalf("expected second response to contain %q, got %q", "other-2", w2.Body.String())
|
t.Fatal("concurrent requests deadlocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if !strings.Contains(w3.Body.String(), "call-3") {
|
|
||||||
t.Fatalf("expected evicted response to contain %q, got %q", "call-3", w3.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if grp.counter.Load() != 3 {
|
|
||||||
t.Fatalf("expected counter=3 after eviction, got %d", grp.counter.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithCache_Good_EvictsWhenSizeLimitReached(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
grp := &cacheSizedGroup{}
|
|
||||||
e, _ := api.New(api.WithCacheLimits(5*time.Second, 10, 250))
|
|
||||||
e.Register(grp)
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/small", nil)
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if !strings.Contains(w1.Body.String(), "small-1") {
|
|
||||||
t.Fatalf("expected first response to contain %q, got %q", "small-1", w1.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/large", nil)
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if !strings.Contains(w2.Body.String(), "large-2") {
|
|
||||||
t.Fatalf("expected second response to contain %q, got %q", "large-2", w2.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3, _ := http.NewRequest(http.MethodGet, "/cache/small", nil)
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if !strings.Contains(w3.Body.String(), "small-3") {
|
|
||||||
t.Fatalf("expected size-limited cache to evict the oldest entry, got %q", w3.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := w3.Header().Get("X-Cache"); got != "" {
|
|
||||||
t.Fatalf("expected re-executed response to miss the cache, got X-Cache=%q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if grp.counter.Load() != 3 {
|
|
||||||
t.Fatalf("expected counter=3 after size-based eviction, got %d", grp.counter.Load())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
963
client_test.go
963
client_test.go
|
|
@ -1,963 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_CallOperationByID(t *testing.T) {
|
|
||||||
errCh := make(chan error, 2)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.Query().Get("name"); got != "Ada" {
|
|
||||||
errCh <- fmt.Errorf("expected query name=Ada, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
errCh <- fmt.Errorf("expected POST, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.Query().Get("verbose"); got != "true" {
|
|
||||||
errCh <- fmt.Errorf("expected query verbose=true, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123","name":"Ada"}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/hello:
|
|
||||||
get:
|
|
||||||
operationId: get_hello
|
|
||||||
/users/{id}:
|
|
||||||
post:
|
|
||||||
operationId: update_user
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("get_hello", map[string]any{
|
|
||||||
"name": "Ada",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
hello, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if hello["message"] != "hello" {
|
|
||||||
t.Fatalf("expected message=hello, got %#v", hello["message"])
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = client.Call("update_user", map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"id": "123",
|
|
||||||
},
|
|
||||||
"query": map[string]any{
|
|
||||||
"verbose": true,
|
|
||||||
},
|
|
||||||
"body": map[string]any{
|
|
||||||
"name": "Ada",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if updated["id"] != "123" {
|
|
||||||
t.Fatalf("expected id=123, got %#v", updated["id"])
|
|
||||||
}
|
|
||||||
if updated["name"] != "Ada" {
|
|
||||||
t.Fatalf("expected name=Ada, got %#v", updated["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_LoadsSpecFromReader(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"pong"}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/ping:
|
|
||||||
get:
|
|
||||||
operationId: ping
|
|
||||||
`)),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("ping", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
ping, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if ping["message"] != "pong" {
|
|
||||||
t.Fatalf("expected message=pong, got %#v", ping["message"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_ExposesOperationSnapshots(t *testing.T) {
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
servers:
|
|
||||||
- url: https://api.example.com
|
|
||||||
paths:
|
|
||||||
/users/{id}:
|
|
||||||
post:
|
|
||||||
operationId: update_user
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(api.WithSpec(specPath))
|
|
||||||
|
|
||||||
operations, err := client.Operations()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(operations) != 1 {
|
|
||||||
t.Fatalf("expected 1 operation, got %d", len(operations))
|
|
||||||
}
|
|
||||||
|
|
||||||
op := operations[0]
|
|
||||||
if op.OperationID != "update_user" {
|
|
||||||
t.Fatalf("expected operationId update_user, got %q", op.OperationID)
|
|
||||||
}
|
|
||||||
if op.Method != http.MethodPost {
|
|
||||||
t.Fatalf("expected method POST, got %q", op.Method)
|
|
||||||
}
|
|
||||||
if op.PathTemplate != "/users/{id}" {
|
|
||||||
t.Fatalf("expected path template /users/{id}, got %q", op.PathTemplate)
|
|
||||||
}
|
|
||||||
if !op.HasRequestBody {
|
|
||||||
t.Fatal("expected operation to report a request body")
|
|
||||||
}
|
|
||||||
if len(op.Parameters) != 1 || op.Parameters[0].Name != "id" {
|
|
||||||
t.Fatalf("expected one path parameter snapshot, got %+v", op.Parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
op.Parameters[0].Schema["type"] = "integer"
|
|
||||||
operations[0].PathTemplate = "/mutated"
|
|
||||||
|
|
||||||
again, err := client.Operations()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error on re-read: %v", err)
|
|
||||||
}
|
|
||||||
if again[0].PathTemplate != "/users/{id}" {
|
|
||||||
t.Fatalf("expected snapshot to remain immutable, got %q", again[0].PathTemplate)
|
|
||||||
}
|
|
||||||
if got := again[0].Parameters[0].Schema["type"]; got != "string" {
|
|
||||||
t.Fatalf("expected cloned parameter schema, got %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_ExposesServerSnapshots(t *testing.T) {
|
|
||||||
client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
servers:
|
|
||||||
- url: https://api.example.com
|
|
||||||
- url: /relative
|
|
||||||
paths: {}
|
|
||||||
`)))
|
|
||||||
|
|
||||||
servers, err := client.Servers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(servers, []string{"https://api.example.com", "/relative"}) {
|
|
||||||
t.Fatalf("expected server snapshot to preserve order, got %v", servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
servers[0] = "https://mutated.example.com"
|
|
||||||
again, err := client.Servers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error on re-read: %v", err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(again, []string{"https://api.example.com", "/relative"}) {
|
|
||||||
t.Fatalf("expected server snapshot to be cloned, got %v", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_IteratorsExposeSnapshots(t *testing.T) {
|
|
||||||
client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
servers:
|
|
||||||
- url: https://api.example.com
|
|
||||||
paths:
|
|
||||||
/users/{id}:
|
|
||||||
post:
|
|
||||||
operationId: update_user
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
`)))
|
|
||||||
|
|
||||||
operations, err := client.OperationsIter()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var operationIDs []string
|
|
||||||
for op := range operations {
|
|
||||||
operationIDs = append(operationIDs, op.OperationID)
|
|
||||||
}
|
|
||||||
if !slices.Equal(operationIDs, []string{"update_user"}) {
|
|
||||||
t.Fatalf("expected iterator to preserve operation snapshots, got %v", operationIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
servers, err := client.ServersIter()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverURLs []string
|
|
||||||
for server := range servers {
|
|
||||||
serverURLs = append(serverURLs, server)
|
|
||||||
}
|
|
||||||
if !slices.Equal(serverURLs, []string{"https://api.example.com"}) {
|
|
||||||
t.Fatalf("expected iterator to preserve server snapshots, got %v", serverURLs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/head", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodHead {
|
|
||||||
errCh <- fmt.Errorf("expected HEAD, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.RawQuery; got != "" {
|
|
||||||
errCh <- fmt.Errorf("expected no query string, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
errCh <- fmt.Errorf("read body: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if string(body) != `{"name":"Ada"}` {
|
|
||||||
errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body))
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/head:
|
|
||||||
head:
|
|
||||||
operationId: head_check
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("head_check", map[string]any{
|
|
||||||
"name": "Ada",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
t.Fatalf("expected nil result for empty HEAD response body, got %T", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_CallOperationWithRepeatedQueryValues(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.Query()["tag"]; len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
|
|
||||||
errCh <- fmt.Errorf("expected repeated tag values [alpha beta], got %v", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.Query().Get("page"); got != "2" {
|
|
||||||
errCh <- fmt.Errorf("expected page=2, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/search:
|
|
||||||
get:
|
|
||||||
operationId: search_items
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("search_items", map[string]any{
|
|
||||||
"tag": []string{"alpha", "beta"},
|
|
||||||
"page": 2,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
|
|
||||||
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_UsesTopLevelQueryParametersOnPost(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
errCh <- fmt.Errorf("expected POST, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.URL.Query().Get("verbose"); got != "true" {
|
|
||||||
errCh <- fmt.Errorf("expected query verbose=true, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
errCh <- fmt.Errorf("read body: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if string(body) != `{"name":"Ada"}` {
|
|
||||||
errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body))
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/submit:
|
|
||||||
post:
|
|
||||||
operationId: submit_item
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
parameters:
|
|
||||||
- name: verbose
|
|
||||||
in: query
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("submit_item", map[string]any{
|
|
||||||
"verbose": true,
|
|
||||||
"name": "Ada",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
|
|
||||||
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_MissingRequiredQueryParameter(t *testing.T) {
|
|
||||||
called := make(chan struct{}, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
called <- struct{}{}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/submit:
|
|
||||||
post:
|
|
||||||
operationId: submit_item
|
|
||||||
parameters:
|
|
||||||
- name: verbose
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("submit_item", map[string]any{
|
|
||||||
"name": "Ada",
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected required query parameter validation error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-called:
|
|
||||||
t.Fatal("expected validation to fail before the HTTP call")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_ValidatesQueryParameterAgainstSchema(t *testing.T) {
|
|
||||||
called := make(chan struct{}, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
called <- struct{}{}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/search:
|
|
||||||
get:
|
|
||||||
operationId: search_items
|
|
||||||
parameters:
|
|
||||||
- name: page
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("search_items", map[string]any{
|
|
||||||
"page": "two",
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected query parameter validation error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-called:
|
|
||||||
t.Fatal("expected validation to fail before the HTTP call")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_ValidatesPathParameterAgainstSchema(t *testing.T) {
|
|
||||||
called := make(chan struct{}, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
called <- struct{}{}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/users/{id}:
|
|
||||||
get:
|
|
||||||
operationId: get_user
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("get_user", map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"id": "abc",
|
|
||||||
},
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected path parameter validation error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-called:
|
|
||||||
t.Fatal("expected validation to fail before the HTTP call")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_UsesHeaderAndCookieParameters(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/inspect", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.Header.Get("X-Trace-ID"); got != "trace-123" {
|
|
||||||
errCh <- fmt.Errorf("expected X-Trace-ID=trace-123, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := r.Header.Get("X-Custom-Header"); got != "custom-value" {
|
|
||||||
errCh <- fmt.Errorf("expected X-Custom-Header=custom-value, got %q", got)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session, err := r.Cookie("session_id")
|
|
||||||
if err != nil {
|
|
||||||
errCh <- fmt.Errorf("expected session_id cookie: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if session.Value != "cookie-123" {
|
|
||||||
errCh <- fmt.Errorf("expected session_id=cookie-123, got %q", session.Value)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pref, err := r.Cookie("pref")
|
|
||||||
if err != nil {
|
|
||||||
errCh <- fmt.Errorf("expected pref cookie: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if pref.Value != "dark" {
|
|
||||||
errCh <- fmt.Errorf("expected pref=dark, got %q", pref.Value)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/inspect:
|
|
||||||
get:
|
|
||||||
operationId: inspect_request
|
|
||||||
parameters:
|
|
||||||
- name: X-Trace-ID
|
|
||||||
in: header
|
|
||||||
- name: session_id
|
|
||||||
in: cookie
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("inspect_request", map[string]any{
|
|
||||||
"X-Trace-ID": "trace-123",
|
|
||||||
"session_id": "cookie-123",
|
|
||||||
"header": map[string]any{
|
|
||||||
"X-Custom-Header": "custom-value",
|
|
||||||
},
|
|
||||||
"cookie": map[string]any{
|
|
||||||
"pref": "dark",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
|
|
||||||
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Good_UsesFirstAbsoluteServer(t *testing.T) {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
servers:
|
|
||||||
- url: " `+srv.URL+` "
|
|
||||||
- url: /
|
|
||||||
- url: " `+srv.URL+` "
|
|
||||||
paths:
|
|
||||||
/hello:
|
|
||||||
get:
|
|
||||||
operationId: get_hello
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
)
|
|
||||||
|
|
||||||
result, err := client.Call("get_hello", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
t.Fatal(err)
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
hello, ok := result.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected map result, got %T", result)
|
|
||||||
}
|
|
||||||
if hello["message"] != "hello" {
|
|
||||||
t.Fatalf("expected message=hello, got %#v", hello["message"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_ValidatesRequestBodyAgainstSchema(t *testing.T) {
|
|
||||||
called := make(chan struct{}, 1)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
called <- struct{}{}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123"}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/users:
|
|
||||||
post:
|
|
||||||
operationId: create_user
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [name]
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
success:
|
|
||||||
type: boolean
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("create_user", map[string]any{
|
|
||||||
"body": map[string]any{},
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected request body validation error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-called:
|
|
||||||
t.Fatal("expected request validation to fail before the HTTP call")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_ValidatesResponseAgainstSchema(t *testing.T) {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"success":true,"data":{"id":123}}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths:
|
|
||||||
/users:
|
|
||||||
get:
|
|
||||||
operationId: list_users
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [success, data]
|
|
||||||
properties:
|
|
||||||
success:
|
|
||||||
type: boolean
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
required: [id]
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL(srv.URL),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("list_users", nil); err == nil {
|
|
||||||
t.Fatal("expected response validation error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenAPIClient_Bad_MissingOperation(t *testing.T) {
|
|
||||||
specPath := writeTempSpec(t, `openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths: {}
|
|
||||||
`)
|
|
||||||
|
|
||||||
client := api.NewOpenAPIClient(
|
|
||||||
api.WithSpec(specPath),
|
|
||||||
api.WithBaseURL("http://example.invalid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := client.Call("missing", nil); err == nil {
|
|
||||||
t.Fatal("expected error for missing operation, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTempSpec(t *testing.T, contents string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "openapi.yaml")
|
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
||||||
t.Fatalf("write spec: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
@ -8,12 +8,7 @@ func init() {
|
||||||
cli.RegisterCommands(AddAPICommands)
|
cli.RegisterCommands(AddAPICommands)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAPICommands registers the `api` command group.
|
// AddAPICommands registers the 'api' command group.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// root := &cli.Command{Use: "root"}
|
|
||||||
// api.AddAPICommands(root)
|
|
||||||
func AddAPICommands(root *cli.Command) {
|
func AddAPICommands(root *cli.Command) {
|
||||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||||
root.AddCommand(apiCmd)
|
root.AddCommand(apiCmd)
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// splitUniqueCSV trims and deduplicates a comma-separated list while
|
|
||||||
// preserving the first occurrence of each value.
|
|
||||||
func splitUniqueCSV(raw string) []string {
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
values := make([]string, 0, len(parts))
|
|
||||||
seen := make(map[string]struct{}, len(parts))
|
|
||||||
|
|
||||||
for _, part := range parts {
|
|
||||||
value := strings.TrimSpace(part)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[value]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalisePublicPaths trims whitespace, ensures a leading slash, and removes
|
|
||||||
// duplicate entries while preserving the first occurrence of each path.
|
|
||||||
func normalisePublicPaths(paths []string) []string {
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(paths))
|
|
||||||
seen := make(map[string]struct{}, len(paths))
|
|
||||||
|
|
||||||
for _, path := range paths {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
path = strings.TrimRight(path, "/")
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
if _, ok := seen[path]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[path] = struct{}{}
|
|
||||||
out = append(out, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -3,91 +3,82 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"iter"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"dappco.re/go/core"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
corelog "dappco.re/go/core/log"
|
||||||
|
|
||||||
goapi "dappco.re/go/core/api"
|
goapi "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
defaultSDKTitle = "Lethean Core API"
|
|
||||||
defaultSDKDescription = "Lethean Core API"
|
|
||||||
defaultSDKVersion = "1.0.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
func addSDKCommand(parent *cli.Command) {
|
func addSDKCommand(parent *cli.Command) {
|
||||||
var (
|
var (
|
||||||
lang string
|
lang string
|
||||||
output string
|
output string
|
||||||
specFile string
|
specFile string
|
||||||
packageName string
|
packageName string
|
||||||
cfg specBuilderConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg.title = defaultSDKTitle
|
|
||||||
cfg.description = defaultSDKDescription
|
|
||||||
cfg.version = defaultSDKVersion
|
|
||||||
|
|
||||||
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
||||||
languages := splitUniqueCSV(lang)
|
if lang == "" {
|
||||||
if len(languages) == 0 {
|
return coreerr.E("sdk.Generate", "--lang is required. Supported: "+core.Join(", ", goapi.SupportedLanguages()...), nil)
|
||||||
return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gen := &goapi.SDKGenerator{
|
// If no spec file provided, generate one to a temp file.
|
||||||
OutputDir: output,
|
|
||||||
PackageName: packageName,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !gen.Available() {
|
|
||||||
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
|
|
||||||
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
|
|
||||||
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
|
|
||||||
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no spec file was provided, generate one only after confirming the
|
|
||||||
// generator is available.
|
|
||||||
if specFile == "" {
|
if specFile == "" {
|
||||||
builder, err := sdkSpecBuilder(cfg)
|
builder := &goapi.SpecBuilder{
|
||||||
if err != nil {
|
Title: "Lethean Core API",
|
||||||
return err
|
Description: "Lethean Core API",
|
||||||
|
Version: "1.0.0",
|
||||||
}
|
}
|
||||||
groups := sdkSpecGroupsIter()
|
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
bridge := goapi.NewToolBridge("/tools")
|
||||||
|
groups := []goapi.RouteGroup{bridge}
|
||||||
|
|
||||||
|
tmpPath := core.Path(core.Env("DIR_TMP"), "openapi-spec.json")
|
||||||
|
writer, err := coreio.Local.Create(tmpPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("sdk.Generate", "create temp spec file", err)
|
return coreerr.E("sdk.Generate", "create temp spec file", err)
|
||||||
}
|
}
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
|
||||||
_ = coreio.Local.Delete(tmpPath)
|
|
||||||
return coreerr.E("sdk.Generate", "close temp spec file", err)
|
|
||||||
}
|
|
||||||
defer coreio.Local.Delete(tmpPath)
|
|
||||||
|
|
||||||
if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil {
|
if err := goapi.ExportSpec(writer, "json", builder, groups); err != nil {
|
||||||
|
writer.Close()
|
||||||
return coreerr.E("sdk.Generate", "generate spec", err)
|
return coreerr.E("sdk.Generate", "generate spec", err)
|
||||||
}
|
}
|
||||||
|
writer.Close()
|
||||||
|
defer coreio.Local.Delete(tmpPath)
|
||||||
specFile = tmpPath
|
specFile = tmpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
gen.SpecPath = specFile
|
gen := &goapi.SDKGenerator{
|
||||||
|
SpecPath: specFile,
|
||||||
|
OutputDir: output,
|
||||||
|
PackageName: packageName,
|
||||||
|
Stdout: cmd.OutOrStdout(),
|
||||||
|
Stderr: cmd.ErrOrStderr(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gen.Available() {
|
||||||
|
corelog.Error("openapi-generator-cli not found. Install with:")
|
||||||
|
corelog.Error(" brew install openapi-generator (macOS)")
|
||||||
|
corelog.Error(" npm install @openapitools/openapi-generator-cli -g")
|
||||||
|
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate for each language.
|
// Generate for each language.
|
||||||
for _, l := range languages {
|
for _, language := range core.Split(lang, ",") {
|
||||||
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
|
language = core.Trim(language)
|
||||||
if err := gen.Generate(cli.Context(), l); err != nil {
|
if language == "" {
|
||||||
return coreerr.E("sdk.Generate", "generate "+l, err)
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
corelog.Info("generating " + language + " SDK...")
|
||||||
|
if err := gen.Generate(context.Background(), language); err != nil {
|
||||||
|
return coreerr.E("sdk.Generate", "generate "+language, err)
|
||||||
|
}
|
||||||
|
corelog.Info("done: " + output + "/" + language + "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -95,50 +86,8 @@ func addSDKCommand(parent *cli.Command) {
|
||||||
|
|
||||||
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
|
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
|
||||||
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
||||||
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to an existing OpenAPI spec (generates a temporary spec from registered route groups and the built-in tool bridge if not provided)")
|
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to existing OpenAPI spec (generates from MCP tools if not provided)")
|
||||||
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
|
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
|
||||||
registerSpecBuilderFlags(cmd, &cfg)
|
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
|
|
||||||
return newSpecBuilder(specBuilderConfig{
|
|
||||||
title: cfg.title,
|
|
||||||
summary: cfg.summary,
|
|
||||||
description: cfg.description,
|
|
||||||
version: cfg.version,
|
|
||||||
swaggerPath: cfg.swaggerPath,
|
|
||||||
graphqlPath: cfg.graphqlPath,
|
|
||||||
graphqlPlayground: cfg.graphqlPlayground,
|
|
||||||
graphqlPlaygroundPath: cfg.graphqlPlaygroundPath,
|
|
||||||
ssePath: cfg.ssePath,
|
|
||||||
wsPath: cfg.wsPath,
|
|
||||||
pprofEnabled: cfg.pprofEnabled,
|
|
||||||
expvarEnabled: cfg.expvarEnabled,
|
|
||||||
cacheEnabled: cfg.cacheEnabled,
|
|
||||||
cacheTTL: cfg.cacheTTL,
|
|
||||||
cacheMaxEntries: cfg.cacheMaxEntries,
|
|
||||||
cacheMaxBytes: cfg.cacheMaxBytes,
|
|
||||||
i18nDefaultLocale: cfg.i18nDefaultLocale,
|
|
||||||
i18nSupportedLocales: cfg.i18nSupportedLocales,
|
|
||||||
authentikIssuer: cfg.authentikIssuer,
|
|
||||||
authentikClientID: cfg.authentikClientID,
|
|
||||||
authentikTrustedProxy: cfg.authentikTrustedProxy,
|
|
||||||
authentikPublicPaths: cfg.authentikPublicPaths,
|
|
||||||
termsURL: cfg.termsURL,
|
|
||||||
contactName: cfg.contactName,
|
|
||||||
contactURL: cfg.contactURL,
|
|
||||||
contactEmail: cfg.contactEmail,
|
|
||||||
licenseName: cfg.licenseName,
|
|
||||||
licenseURL: cfg.licenseURL,
|
|
||||||
externalDocsDescription: cfg.externalDocsDescription,
|
|
||||||
externalDocsURL: cfg.externalDocsURL,
|
|
||||||
servers: cfg.servers,
|
|
||||||
securitySchemes: cfg.securitySchemes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
|
|
||||||
return specGroupsIter(goapi.NewToolBridge("/tools"))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,103 +3,50 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
corelog "dappco.re/go/core/log"
|
||||||
|
|
||||||
goapi "dappco.re/go/core/api"
|
goapi "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addSpecCommand(parent *cli.Command) {
|
func addSpecCommand(parent *cli.Command) {
|
||||||
var (
|
var (
|
||||||
output string
|
output string
|
||||||
format string
|
format string
|
||||||
cfg specBuilderConfig
|
title string
|
||||||
|
version string
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg.title = "Lethean Core API"
|
|
||||||
cfg.description = "Lethean Core API"
|
|
||||||
cfg.version = "1.0.0"
|
|
||||||
|
|
||||||
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
||||||
// Build spec from all route groups registered for CLI generation.
|
// Build spec from registered route groups.
|
||||||
builder, err := newSpecBuilder(cfg)
|
// Additional groups can be added here as the platform grows.
|
||||||
if err != nil {
|
builder := &goapi.SpecBuilder{
|
||||||
return err
|
Title: title,
|
||||||
|
Description: "Lethean Core API",
|
||||||
|
Version: version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start with the default tool bridge — future versions will
|
||||||
|
// auto-populate from the MCP tool registry once the bridge
|
||||||
|
// integration lands in the local go-ai module.
|
||||||
bridge := goapi.NewToolBridge("/tools")
|
bridge := goapi.NewToolBridge("/tools")
|
||||||
groups := specGroupsIter(bridge)
|
groups := []goapi.RouteGroup{bridge}
|
||||||
|
|
||||||
if output != "" {
|
if output != "" {
|
||||||
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
corelog.Info("spec written to " + output)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return goapi.ExportSpecIter(os.Stdout, format, builder, groups)
|
return goapi.ExportSpec(cmd.OutOrStdout(), format, builder, groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
|
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
|
||||||
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
||||||
registerSpecBuilderFlags(cmd, &cfg)
|
cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec")
|
||||||
|
cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec")
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseServers(raw string) []string {
|
|
||||||
return splitUniqueCSV(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSecuritySchemes(raw string) (map[string]any, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemes map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(raw), &schemes); err != nil {
|
|
||||||
return nil, cli.Err("invalid security schemes JSON: %w", err)
|
|
||||||
}
|
|
||||||
return schemes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) {
|
|
||||||
cli.StringFlag(cmd, &cfg.title, "title", "t", cfg.title, "API title in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.summary, "summary", "", cfg.summary, "OpenAPI info summary in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.description, "description", "d", cfg.description, "API description in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.version, "version", "V", cfg.version, "API version in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.swaggerPath, "swagger-path", "", "", "Swagger UI path in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.graphqlPath, "graphql-path", "", "", "GraphQL endpoint path in generated spec")
|
|
||||||
cli.BoolFlag(cmd, &cfg.graphqlPlayground, "graphql-playground", "", false, "Include the GraphQL playground endpoint in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.graphqlPlaygroundPath, "graphql-playground-path", "", "", "GraphQL playground path in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.ssePath, "sse-path", "", "", "SSE endpoint path in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.wsPath, "ws-path", "", "", "WebSocket endpoint path in generated spec")
|
|
||||||
cli.BoolFlag(cmd, &cfg.pprofEnabled, "pprof", "", false, "Include pprof endpoints in generated spec")
|
|
||||||
cli.BoolFlag(cmd, &cfg.expvarEnabled, "expvar", "", false, "Include expvar endpoint in generated spec")
|
|
||||||
cli.BoolFlag(cmd, &cfg.cacheEnabled, "cache", "", false, "Include cache metadata in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.cacheTTL, "cache-ttl", "", "", "Cache TTL in generated spec")
|
|
||||||
cli.IntFlag(cmd, &cfg.cacheMaxEntries, "cache-max-entries", "", 0, "Cache max entries in generated spec")
|
|
||||||
cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec")
|
|
||||||
cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.contactEmail, "contact-email", "", "", "OpenAPI contact email in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.licenseName, "license-name", "", "", "OpenAPI licence name in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.licenseURL, "license-url", "", "", "OpenAPI licence URL in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec")
|
|
||||||
cli.StringFlag(cmd, &cfg.servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
|
|
||||||
cli.StringFlag(cmd, &cfg.securitySchemes, "security-schemes", "", "", "JSON object of custom OpenAPI security schemes")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1314
cmd/api/cmd_test.go
1314
cmd/api/cmd_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,124 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
goapi "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type specBuilderConfig struct {
|
|
||||||
title string
|
|
||||||
summary string
|
|
||||||
description string
|
|
||||||
version string
|
|
||||||
swaggerPath string
|
|
||||||
graphqlPath string
|
|
||||||
graphqlPlayground bool
|
|
||||||
graphqlPlaygroundPath string
|
|
||||||
ssePath string
|
|
||||||
wsPath string
|
|
||||||
pprofEnabled bool
|
|
||||||
expvarEnabled bool
|
|
||||||
cacheEnabled bool
|
|
||||||
cacheTTL string
|
|
||||||
cacheMaxEntries int
|
|
||||||
cacheMaxBytes int
|
|
||||||
i18nDefaultLocale string
|
|
||||||
i18nSupportedLocales string
|
|
||||||
authentikIssuer string
|
|
||||||
authentikClientID string
|
|
||||||
authentikTrustedProxy bool
|
|
||||||
authentikPublicPaths string
|
|
||||||
termsURL string
|
|
||||||
contactName string
|
|
||||||
contactURL string
|
|
||||||
contactEmail string
|
|
||||||
licenseName string
|
|
||||||
licenseURL string
|
|
||||||
externalDocsDescription string
|
|
||||||
externalDocsURL string
|
|
||||||
servers string
|
|
||||||
securitySchemes string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
|
|
||||||
swaggerPath := strings.TrimSpace(cfg.swaggerPath)
|
|
||||||
graphqlPath := strings.TrimSpace(cfg.graphqlPath)
|
|
||||||
ssePath := strings.TrimSpace(cfg.ssePath)
|
|
||||||
wsPath := strings.TrimSpace(cfg.wsPath)
|
|
||||||
cacheTTL := strings.TrimSpace(cfg.cacheTTL)
|
|
||||||
cacheTTLValid := parsePositiveDuration(cacheTTL)
|
|
||||||
|
|
||||||
builder := &goapi.SpecBuilder{
|
|
||||||
Title: strings.TrimSpace(cfg.title),
|
|
||||||
Summary: strings.TrimSpace(cfg.summary),
|
|
||||||
Description: strings.TrimSpace(cfg.description),
|
|
||||||
Version: strings.TrimSpace(cfg.version),
|
|
||||||
SwaggerEnabled: swaggerPath != "",
|
|
||||||
SwaggerPath: swaggerPath,
|
|
||||||
GraphQLEnabled: graphqlPath != "" || cfg.graphqlPlayground,
|
|
||||||
GraphQLPath: graphqlPath,
|
|
||||||
GraphQLPlayground: cfg.graphqlPlayground,
|
|
||||||
GraphQLPlaygroundPath: strings.TrimSpace(cfg.graphqlPlaygroundPath),
|
|
||||||
SSEEnabled: ssePath != "",
|
|
||||||
SSEPath: ssePath,
|
|
||||||
WSEnabled: wsPath != "",
|
|
||||||
WSPath: wsPath,
|
|
||||||
PprofEnabled: cfg.pprofEnabled,
|
|
||||||
ExpvarEnabled: cfg.expvarEnabled,
|
|
||||||
CacheEnabled: cfg.cacheEnabled || cacheTTLValid,
|
|
||||||
CacheTTL: cacheTTL,
|
|
||||||
CacheMaxEntries: cfg.cacheMaxEntries,
|
|
||||||
CacheMaxBytes: cfg.cacheMaxBytes,
|
|
||||||
I18nDefaultLocale: strings.TrimSpace(cfg.i18nDefaultLocale),
|
|
||||||
TermsOfService: strings.TrimSpace(cfg.termsURL),
|
|
||||||
ContactName: strings.TrimSpace(cfg.contactName),
|
|
||||||
ContactURL: strings.TrimSpace(cfg.contactURL),
|
|
||||||
ContactEmail: strings.TrimSpace(cfg.contactEmail),
|
|
||||||
Servers: parseServers(cfg.servers),
|
|
||||||
LicenseName: strings.TrimSpace(cfg.licenseName),
|
|
||||||
LicenseURL: strings.TrimSpace(cfg.licenseURL),
|
|
||||||
ExternalDocsDescription: strings.TrimSpace(cfg.externalDocsDescription),
|
|
||||||
ExternalDocsURL: strings.TrimSpace(cfg.externalDocsURL),
|
|
||||||
AuthentikIssuer: strings.TrimSpace(cfg.authentikIssuer),
|
|
||||||
AuthentikClientID: strings.TrimSpace(cfg.authentikClientID),
|
|
||||||
AuthentikTrustedProxy: cfg.authentikTrustedProxy,
|
|
||||||
AuthentikPublicPaths: normalisePublicPaths(splitUniqueCSV(cfg.authentikPublicPaths)),
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.I18nSupportedLocales = parseLocales(cfg.i18nSupportedLocales)
|
|
||||||
if builder.I18nDefaultLocale == "" && len(builder.I18nSupportedLocales) > 0 {
|
|
||||||
builder.I18nDefaultLocale = "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.securitySchemes != "" {
|
|
||||||
schemes, err := parseSecuritySchemes(cfg.securitySchemes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
builder.SecuritySchemes = schemes
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLocales(raw string) []string {
|
|
||||||
return splitUniqueCSV(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveDuration(raw string) bool {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := time.ParseDuration(raw)
|
|
||||||
if err != nil || d <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"iter"
|
|
||||||
|
|
||||||
goapi "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// specGroupsIter snapshots the registered spec groups and appends one optional
|
|
||||||
// extra group. It keeps the command paths iterator-backed while preserving the
|
|
||||||
// existing ordering guarantees.
|
|
||||||
func specGroupsIter(extra goapi.RouteGroup) iter.Seq[goapi.RouteGroup] {
|
|
||||||
return goapi.SpecGroupsIter(extra)
|
|
||||||
}
|
|
||||||
93
codegen.go
93
codegen.go
|
|
@ -4,17 +4,16 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"io"
|
||||||
"iter"
|
"iter"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
coreexec "dappco.re/go/core/process/exec"
|
||||||
|
coreprocess "dappco.re/go/core/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supported SDK target languages.
|
// Supported SDK target languages.
|
||||||
|
|
@ -34,9 +33,8 @@ var supportedLanguages = map[string]string{
|
||||||
|
|
||||||
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
||||||
//
|
//
|
||||||
// Example:
|
// gen := &api.SDKGenerator{SpecPath: "./openapi.json", OutputDir: "./sdk", PackageName: "myapi"}
|
||||||
//
|
// if gen.Available() { gen.Generate(ctx, "go") }
|
||||||
// gen := &api.SDKGenerator{SpecPath: "./openapi.yaml", OutputDir: "./sdk", PackageName: "service"}
|
|
||||||
type SDKGenerator struct {
|
type SDKGenerator struct {
|
||||||
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
||||||
SpecPath string
|
SpecPath string
|
||||||
|
|
@ -46,57 +44,48 @@ type SDKGenerator struct {
|
||||||
|
|
||||||
// PackageName is the name used for the generated package/module.
|
// PackageName is the name used for the generated package/module.
|
||||||
PackageName string
|
PackageName string
|
||||||
|
|
||||||
|
// Stdout receives command output (defaults to io.Discard when nil).
|
||||||
|
Stdout io.Writer
|
||||||
|
|
||||||
|
// Stderr receives command error output (defaults to io.Discard when nil).
|
||||||
|
Stderr io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates an SDK for the given language using openapi-generator-cli.
|
// Generate creates an SDK for the given language using openapi-generator-cli.
|
||||||
// The language must be one of the supported languages returned by SupportedLanguages().
|
// The language must be one of the supported languages returned by SupportedLanguages().
|
||||||
//
|
//
|
||||||
// Example:
|
// err := gen.Generate(ctx, "go")
|
||||||
//
|
// err := gen.Generate(ctx, "python")
|
||||||
// err := gen.Generate(context.Background(), "go")
|
|
||||||
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
|
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
|
||||||
if g == nil {
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "generator is nil", nil)
|
|
||||||
}
|
|
||||||
if ctx == nil {
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "context is nil", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
language = strings.TrimSpace(language)
|
|
||||||
generator, ok := supportedLanguages[language]
|
generator, ok := supportedLanguages[language]
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("SDKGenerator.Generate", fmt.Sprintf("unsupported language %q: supported languages are %v", language, SupportedLanguages()), nil)
|
return coreerr.E("SDKGenerator.Generate", core.Sprintf("unsupported language %q: supported languages are %v", language, SupportedLanguages()), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
specPath := strings.TrimSpace(g.SpecPath)
|
if !coreio.Local.IsFile(g.SpecPath) {
|
||||||
if specPath == "" {
|
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+g.SpecPath, nil)
|
||||||
return coreerr.E("SDKGenerator.Generate", "spec path is required", nil)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(specPath); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+specPath, nil)
|
|
||||||
}
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "stat spec file", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
outputBase := strings.TrimSpace(g.OutputDir)
|
outputDir := core.Path(g.OutputDir, language)
|
||||||
if outputBase == "" {
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "output directory is required", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !g.Available() {
|
|
||||||
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli not installed", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := filepath.Join(outputBase, language)
|
|
||||||
if err := coreio.Local.EnsureDir(outputDir); err != nil {
|
if err := coreio.Local.EnsureDir(outputDir); err != nil {
|
||||||
return coreerr.E("SDKGenerator.Generate", "create output directory", err)
|
return coreerr.E("SDKGenerator.Generate", "create output directory", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := g.buildArgs(generator, outputDir)
|
args := g.buildArgs(generator, outputDir)
|
||||||
cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...)
|
|
||||||
cmd.Stdout = os.Stdout
|
stdout := g.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
if stdout == nil {
|
||||||
|
stdout = io.Discard
|
||||||
|
}
|
||||||
|
stderr := g.Stderr
|
||||||
|
if stderr == nil {
|
||||||
|
stderr = io.Discard
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := coreexec.Command(ctx, "openapi-generator-cli", args...).
|
||||||
|
WithStdout(stdout).
|
||||||
|
WithStderr(stderr)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli failed for "+language, err)
|
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli failed for "+language, err)
|
||||||
|
|
@ -121,33 +110,23 @@ func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
|
||||||
|
|
||||||
// Available checks if openapi-generator-cli is installed and accessible.
|
// Available checks if openapi-generator-cli is installed and accessible.
|
||||||
//
|
//
|
||||||
// Example:
|
// if gen.Available() { gen.Generate(ctx, "go") }
|
||||||
//
|
|
||||||
// if !gen.Available() {
|
|
||||||
// t.Fatal("openapi-generator-cli is required")
|
|
||||||
// }
|
|
||||||
func (g *SDKGenerator) Available() bool {
|
func (g *SDKGenerator) Available() bool {
|
||||||
_, err := exec.LookPath("openapi-generator-cli")
|
prog := &coreprocess.Program{Name: "openapi-generator-cli"}
|
||||||
return err == nil
|
return prog.Find() == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedLanguages returns the list of supported SDK target languages
|
// SupportedLanguages returns the list of supported SDK target languages
|
||||||
// in sorted order for deterministic output.
|
// in sorted order for deterministic output.
|
||||||
//
|
//
|
||||||
// Example:
|
// langs := api.SupportedLanguages() // ["csharp", "go", "java", ...]
|
||||||
//
|
|
||||||
// langs := api.SupportedLanguages()
|
|
||||||
func SupportedLanguages() []string {
|
func SupportedLanguages() []string {
|
||||||
return slices.Sorted(maps.Keys(supportedLanguages))
|
return slices.Sorted(maps.Keys(supportedLanguages))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
|
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
|
||||||
//
|
//
|
||||||
// Example:
|
// for lang := range api.SupportedLanguagesIter() { fmt.Println(lang) }
|
||||||
//
|
|
||||||
// for lang := range api.SupportedLanguagesIter() {
|
|
||||||
// fmt.Println(lang)
|
|
||||||
// }
|
|
||||||
func SupportedLanguagesIter() iter.Seq[string] {
|
func SupportedLanguagesIter() iter.Seq[string] {
|
||||||
return slices.Values(SupportedLanguages())
|
return slices.Values(SupportedLanguages())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
codegen_test.go
126
codegen_test.go
|
|
@ -59,108 +59,7 @@ func TestSDKGenerator_Bad_MissingSpec(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSDKGenerator_Bad_EmptySpecPath(t *testing.T) {
|
|
||||||
gen := &api.SDKGenerator{
|
|
||||||
OutputDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := gen.Generate(context.Background(), "go")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty spec path, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "spec path is required") {
|
|
||||||
t.Fatalf("expected error to contain 'spec path is required', got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSDKGenerator_Bad_EmptyOutputDir(t *testing.T) {
|
|
||||||
specDir := t.TempDir()
|
|
||||||
specPath := filepath.Join(specDir, "spec.json")
|
|
||||||
if err := os.WriteFile(specPath, []byte(`{"openapi":"3.1.0"}`), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write spec file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gen := &api.SDKGenerator{
|
|
||||||
SpecPath: specPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := gen.Generate(context.Background(), "go")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty output directory, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "output directory is required") {
|
|
||||||
t.Fatalf("expected error to contain 'output directory is required', got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSDKGenerator_Bad_NilContext(t *testing.T) {
|
|
||||||
gen := &api.SDKGenerator{
|
|
||||||
SpecPath: filepath.Join(t.TempDir(), "nonexistent.json"),
|
|
||||||
OutputDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := gen.Generate(nil, "go")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for nil context, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "context is nil") {
|
|
||||||
t.Fatalf("expected error to contain 'context is nil', got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSDKGenerator_Bad_NilReceiver(t *testing.T) {
|
|
||||||
var gen *api.SDKGenerator
|
|
||||||
|
|
||||||
err := gen.Generate(context.Background(), "go")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for nil generator, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "generator is nil") {
|
|
||||||
t.Fatalf("expected error to contain 'generator is nil', got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSDKGenerator_Bad_MissingGenerator(t *testing.T) {
|
|
||||||
t.Setenv("PATH", t.TempDir())
|
|
||||||
|
|
||||||
specDir := t.TempDir()
|
|
||||||
specPath := filepath.Join(specDir, "spec.json")
|
|
||||||
if err := os.WriteFile(specPath, []byte(`{"openapi":"3.1.0"}`), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write spec file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := filepath.Join(t.TempDir(), "nested", "sdk")
|
|
||||||
gen := &api.SDKGenerator{
|
|
||||||
SpecPath: specPath,
|
|
||||||
OutputDir: outputDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := gen.Generate(context.Background(), "go")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when openapi-generator-cli is missing, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "openapi-generator-cli not installed") {
|
|
||||||
t.Fatalf("expected missing-generator error, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, statErr := os.Stat(filepath.Join(outputDir, "go")); !os.IsNotExist(statErr) {
|
|
||||||
t.Fatalf("expected output directory not to be created when generator is missing, got err=%v", statErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
||||||
oldPath := os.Getenv("PATH")
|
|
||||||
|
|
||||||
// Provide a fake openapi-generator-cli so Generate reaches the exec step
|
|
||||||
// without depending on the host environment.
|
|
||||||
binDir := t.TempDir()
|
|
||||||
binPath := filepath.Join(binDir, "openapi-generator-cli")
|
|
||||||
script := []byte("#!/bin/sh\nexit 1\n")
|
|
||||||
if err := os.WriteFile(binPath, script, 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to write fake generator: %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath)
|
|
||||||
|
|
||||||
// Write a minimal spec file so we pass the file-exists check.
|
// Write a minimal spec file so we pass the file-exists check.
|
||||||
specDir := t.TempDir()
|
specDir := t.TempDir()
|
||||||
specPath := filepath.Join(specDir, "spec.json")
|
specPath := filepath.Join(specDir, "spec.json")
|
||||||
|
|
@ -174,8 +73,8 @@ func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
||||||
OutputDir: outputDir,
|
OutputDir: outputDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate will fail at the exec step, but the output directory should have
|
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
|
||||||
// been created before the CLI returned its non-zero status.
|
// but the output directory should have been created before that.
|
||||||
_ = gen.Generate(context.Background(), "go")
|
_ = gen.Generate(context.Background(), "go")
|
||||||
|
|
||||||
expected := filepath.Join(outputDir, "go")
|
expected := filepath.Join(outputDir, "go")
|
||||||
|
|
@ -193,3 +92,24 @@ func TestSDKGenerator_Good_Available(t *testing.T) {
|
||||||
// Just verify it returns a bool and does not panic.
|
// Just verify it returns a bool and does not panic.
|
||||||
_ = gen.Available()
|
_ = gen.Available()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportedLanguages_Ugly_CalledRepeatedly(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("SupportedLanguages called repeatedly panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Calling multiple times should always return the same sorted slice.
|
||||||
|
first := api.SupportedLanguages()
|
||||||
|
second := api.SupportedLanguages()
|
||||||
|
|
||||||
|
if len(first) != len(second) {
|
||||||
|
t.Fatalf("inconsistent results: %d vs %d", len(first), len(second))
|
||||||
|
}
|
||||||
|
for languageIndex, language := range first {
|
||||||
|
if language != second[languageIndex] {
|
||||||
|
t.Fatalf("mismatch at index %d: %q vs %q", languageIndex, language, second[languageIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ type Engine struct {
|
||||||
swaggerTitle string
|
swaggerTitle string
|
||||||
swaggerDesc string
|
swaggerDesc string
|
||||||
swaggerVersion string
|
swaggerVersion string
|
||||||
swaggerExternalDocsDescription string
|
|
||||||
swaggerExternalDocsURL string
|
|
||||||
pprofEnabled bool
|
pprofEnabled bool
|
||||||
expvarEnabled bool
|
expvarEnabled bool
|
||||||
graphql *graphqlConfig
|
graphql *graphqlConfig
|
||||||
|
|
@ -130,9 +128,6 @@ type RouteDescription struct {
|
||||||
Summary string
|
Summary string
|
||||||
Description string
|
Description string
|
||||||
Tags []string
|
Tags []string
|
||||||
Deprecated bool
|
|
||||||
StatusCode int
|
|
||||||
Parameters []ParameterDescription
|
|
||||||
RequestBody map[string]any
|
RequestBody map[string]any
|
||||||
Response map[string]any
|
Response map[string]any
|
||||||
}
|
}
|
||||||
|
|
@ -156,19 +151,12 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
|
||||||
| `WithAddr(addr)` | Listen address | Default `:8080` |
|
| `WithAddr(addr)` | Listen address | Default `:8080` |
|
||||||
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
||||||
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
|
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
|
||||||
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
|
|
||||||
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
|
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
|
||||||
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests`; `X-RateLimit-*` on success; `Retry-After` on rejection; zero or negative disables |
|
|
||||||
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
|
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
|
||||||
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
|
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
|
||||||
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
|
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
|
||||||
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
|
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
|
||||||
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
|
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
|
||||||
| `WithSwaggerTermsOfService(url)` | OpenAPI terms of service metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
|
||||||
| `WithSwaggerContact(name, url, email)` | OpenAPI contact metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
|
||||||
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
|
|
||||||
| `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
|
||||||
| `WithSwaggerExternalDocs(description, url)` | OpenAPI external documentation metadata | Populates the top-level `externalDocs` block without manual `SpecBuilder` wiring |
|
|
||||||
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
|
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
|
||||||
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
|
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
|
||||||
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
|
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
|
||||||
|
|
@ -176,8 +164,7 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
|
||||||
| `WithBrotli(level...)` | Brotli response compression | Writer pool for efficiency; default compression if level omitted |
|
| `WithBrotli(level...)` | Brotli response compression | Writer pool for efficiency; default compression if level omitted |
|
||||||
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
|
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
|
||||||
| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout |
|
| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout |
|
||||||
| `WithCache(ttl)` | In-memory GET response caching | Compatibility wrapper for `WithCacheLimits(ttl, 0, 0)`; `X-Cache: HIT` header on cache hits; 2xx only |
|
| `WithCache(ttl)` | In-memory GET response caching | `X-Cache: HIT` header on cache hits; 2xx only |
|
||||||
| `WithCacheLimits(ttl, maxEntries, maxBytes)` | In-memory GET response caching with explicit bounds | Clearer cache configuration when eviction policy should be self-documenting |
|
|
||||||
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store |
|
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store |
|
||||||
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
|
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
|
||||||
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
|
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
|
||||||
|
|
@ -384,19 +371,14 @@ redirects and introspection). The GraphQL handler is created via gqlgen's
|
||||||
|
|
||||||
## 8. Response Caching
|
## 8. Response Caching
|
||||||
|
|
||||||
`WithCacheLimits(ttl, maxEntries, maxBytes)` installs a URL-keyed in-memory response cache scoped to GET requests:
|
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests:
|
||||||
|
|
||||||
```go
|
|
||||||
engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
|
|
||||||
```
|
|
||||||
|
|
||||||
- Only successful 2xx responses are cached.
|
- Only successful 2xx responses are cached.
|
||||||
- Non-GET methods pass through uncached.
|
- Non-GET methods pass through uncached.
|
||||||
- Cached responses are served with an `X-Cache: HIT` header.
|
- Cached responses are served with an `X-Cache: HIT` header.
|
||||||
- Expired entries are evicted lazily on the next access for the same key.
|
- Expired entries are evicted lazily on the next access for the same key.
|
||||||
- The cache is not shared across `Engine` instances.
|
- The cache is not shared across `Engine` instances.
|
||||||
- `WithCache(ttl)` remains available as a compatibility wrapper for callers that do not need to spell out the bounds.
|
- There is no size limit on the cache.
|
||||||
- Passing non-positive values to `WithCacheLimits` leaves that limit unbounded.
|
|
||||||
|
|
||||||
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
|
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
|
||||||
capture the response body and status code for storage.
|
capture the response body and status code for storage.
|
||||||
|
|
@ -591,9 +573,7 @@ Generates an OpenAPI 3.1 specification from registered route groups.
|
||||||
| `--output` | `-o` | (stdout) | Write spec to file |
|
| `--output` | `-o` | (stdout) | Write spec to file |
|
||||||
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
|
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
|
||||||
| `--title` | `-t` | `Lethean Core API` | API title |
|
| `--title` | `-t` | `Lethean Core API` | API title |
|
||||||
| `--description` | `-d` | `Lethean Core API` | API description |
|
|
||||||
| `--version` | `-V` | `1.0.0` | API version |
|
| `--version` | `-V` | `1.0.0` | API version |
|
||||||
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
|
|
||||||
|
|
||||||
### `core api sdk`
|
### `core api sdk`
|
||||||
|
|
||||||
|
|
@ -605,10 +585,6 @@ Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`.
|
||||||
| `--output` | `-o` | `./sdk` | Output directory |
|
| `--output` | `-o` | `./sdk` | Output directory |
|
||||||
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
|
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
|
||||||
| `--package` | `-p` | `lethean` | Package name for generated SDK |
|
| `--package` | `-p` | `lethean` | Package name for generated SDK |
|
||||||
| `--title` | `-t` | `Lethean Core API` | API title in generated spec |
|
|
||||||
| `--description` | `-d` | `Lethean Core API` | API description in generated spec |
|
|
||||||
| `--version` | `-V` | `1.0.0` | API version in generated spec |
|
|
||||||
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,12 +169,11 @@ At the end of Phase 3, the module has 176 tests.
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
### 1. Cache remains in-memory
|
### 1. Cache has no size limit
|
||||||
|
|
||||||
`WithCache(ttl, maxEntries, maxBytes)` can now bound the cache by entry count and approximate
|
`WithCache(ttl)` stores all successful GET responses in memory with no maximum entry count or
|
||||||
payload size, but it still stores responses in memory. Workloads with very large cached bodies
|
total size bound. For a server receiving requests to many distinct URLs, the cache will grow
|
||||||
or a long-lived process will still consume RAM, so a disk-backed cache would be the next step if
|
without bound. A LRU eviction policy or a configurable maximum is the natural next step.
|
||||||
that becomes a concern.
|
|
||||||
|
|
||||||
### 2. SDK codegen requires an external binary
|
### 2. SDK codegen requires an external binary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ func main() {
|
||||||
api.WithSecure(),
|
api.WithSecure(),
|
||||||
api.WithSlog(nil),
|
api.WithSlog(nil),
|
||||||
api.WithSwagger("My API", "A service description", "1.0.0"),
|
api.WithSwagger("My API", "A service description", "1.0.0"),
|
||||||
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
engine.Register(myRoutes) // any RouteGroup implementation
|
engine.Register(myRoutes) // any RouteGroup implementation
|
||||||
|
|
@ -95,7 +94,7 @@ engine.Register(&Routes{service: svc})
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
||||||
| `options.go` | All `With*()` option functions (28 options) |
|
| `options.go` | All `With*()` option functions (25 options) |
|
||||||
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
||||||
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
||||||
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
||||||
|
|
|
||||||
94
export.go
94
export.go
|
|
@ -3,112 +3,58 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"iter"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportSpec generates the OpenAPI spec and writes it to w.
|
// ExportSpec generates the OpenAPI spec and writes it to w.
|
||||||
// Format must be "json" or "yaml".
|
// Format must be "json" or "yaml".
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// _ = api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups())
|
|
||||||
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
||||||
data, err := builder.Build(groups)
|
data, err := builder.Build(groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("ExportSpec", "build spec", err)
|
return coreerr.E("ExportSpec", "build spec", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return writeSpec(w, format, data, "ExportSpec")
|
switch format {
|
||||||
}
|
|
||||||
|
|
||||||
// ExportSpecIter generates the OpenAPI spec from an iterator and writes it to w.
|
|
||||||
// Format must be "json" or "yaml".
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// _ = api.ExportSpecIter(os.Stdout, "json", builder, api.RegisteredSpecGroupsIter())
|
|
||||||
func ExportSpecIter(w io.Writer, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
|
|
||||||
data, err := builder.BuildIter(groups)
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("ExportSpecIter", "build spec", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeSpec(w, format, data, "ExportSpecIter")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeSpec(w io.Writer, format string, data []byte, op string) error {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
|
||||||
case "json":
|
case "json":
|
||||||
_, err := w.Write(data)
|
_, err = w.Write(data)
|
||||||
return err
|
return err
|
||||||
case "yaml":
|
case "yaml":
|
||||||
// Unmarshal JSON then re-marshal as YAML.
|
// Unmarshal JSON then re-marshal as YAML.
|
||||||
var obj any
|
var obj any
|
||||||
if err := json.Unmarshal(data, &obj); err != nil {
|
result := core.JSONUnmarshal(data, &obj)
|
||||||
return coreerr.E(op, "unmarshal spec", err)
|
if !result.OK {
|
||||||
|
return coreerr.E("ExportSpec", "unmarshal spec", result.Value.(error))
|
||||||
}
|
}
|
||||||
enc := yaml.NewEncoder(w)
|
encoder := yaml.NewEncoder(w)
|
||||||
enc.SetIndent(2)
|
encoder.SetIndent(2)
|
||||||
if err := enc.Encode(obj); err != nil {
|
if err := encoder.Encode(obj); err != nil {
|
||||||
return coreerr.E(op, "encode yaml", err)
|
return coreerr.E("ExportSpec", "encode yaml", err)
|
||||||
}
|
}
|
||||||
return enc.Close()
|
return encoder.Close()
|
||||||
default:
|
default:
|
||||||
return coreerr.E(op, fmt.Sprintf("unsupported format %s: use %q or %q", format, "json", "yaml"), nil)
|
return coreerr.E("ExportSpec", "unsupported format "+format+": use \"json\" or \"yaml\"", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSpecToFile writes the spec to the given path.
|
// ExportSpecToFile writes the spec to the given path.
|
||||||
// The parent directory is created if it does not exist.
|
// The parent directory is created if it does not exist.
|
||||||
//
|
//
|
||||||
// Example:
|
// err := api.ExportSpecToFile("./docs/openapi.json", "json", builder, groups)
|
||||||
//
|
// err := api.ExportSpecToFile("./docs/openapi.yaml", "yaml", builder, groups)
|
||||||
// _ = api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())
|
|
||||||
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
||||||
return exportSpecToFile(path, "ExportSpecToFile", func(w io.Writer) error {
|
if err := coreio.Local.EnsureDir(core.PathDir(path)); err != nil {
|
||||||
return ExportSpec(w, format, builder, groups)
|
return coreerr.E("ExportSpecToFile", "create directory", err)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportSpecToFileIter writes the OpenAPI spec from an iterator to the given path.
|
|
||||||
// The parent directory is created if it does not exist.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// _ = api.ExportSpecToFileIter("./api/openapi.json", "json", builder, api.RegisteredSpecGroupsIter())
|
|
||||||
func ExportSpecToFileIter(path, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
|
|
||||||
return exportSpecToFile(path, "ExportSpecToFileIter", func(w io.Writer) error {
|
|
||||||
return ExportSpecIter(w, format, builder, groups)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportSpecToFile(path, op string, write func(io.Writer) error) (err error) {
|
|
||||||
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
|
|
||||||
return coreerr.E(op, "create directory", err)
|
|
||||||
}
|
}
|
||||||
f, err := os.Create(path)
|
writer, err := coreio.Local.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E(op, "create file", err)
|
return coreerr.E("ExportSpecToFile", "create file", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer writer.Close()
|
||||||
if closeErr := f.Close(); closeErr != nil && err == nil {
|
return ExportSpec(writer, format, builder, groups)
|
||||||
err = coreerr.E(op, "close file", closeErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err = write(f); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ package api_test
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"iter"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -66,24 +65,6 @@ func TestExportSpec_Good_YAML(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportSpec_Good_NormalisesFormatInput(t *testing.T) {
|
|
||||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := api.ExportSpec(&buf, " YAML ", builder, nil); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := yaml.Unmarshal(buf.Bytes(), &spec); err != nil {
|
|
||||||
t.Fatalf("output is not valid YAML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if spec["openapi"] != "3.1.0" {
|
|
||||||
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExportSpec_Bad_InvalidFormat(t *testing.T) {
|
func TestExportSpec_Bad_InvalidFormat(t *testing.T) {
|
||||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
||||||
|
|
||||||
|
|
@ -184,40 +165,18 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportSpecIter_Good_WithGroupIterator(t *testing.T) {
|
func TestExportSpec_Ugly_EmptyFormatDoesNotPanic(t *testing.T) {
|
||||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("ExportSpec with empty format panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
group := &specStubGroup{
|
builder := &api.SpecBuilder{Title: "Test", Version: "1.0.0"}
|
||||||
name: "iter",
|
var output strings.Builder
|
||||||
basePath: "/iter",
|
// Unknown format should return an error, not panic.
|
||||||
descs: []api.RouteDescription{
|
err := api.ExportSpec(&output, "xml", builder, nil)
|
||||||
{
|
if err == nil {
|
||||||
Method: "GET",
|
t.Fatal("expected error for unsupported format, got nil")
|
||||||
Path: "/ping",
|
|
||||||
Summary: "Ping iter group",
|
|
||||||
Response: map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
groups := iter.Seq[api.RouteGroup](func(yield func(api.RouteGroup) bool) {
|
|
||||||
_ = yield(group)
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := api.ExportSpecIter(&buf, "json", builder, groups); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(buf.Bytes(), &spec); err != nil {
|
|
||||||
t.Fatalf("output is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
paths := spec["paths"].(map[string]any)
|
|
||||||
if _, ok := paths["/iter/ping"]; !ok {
|
|
||||||
t.Fatal("expected /iter/ping path in spec")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,27 @@ func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) {
|
||||||
t.Fatalf("expected 404 for /debug/vars without WithExpvar, got %d", w.Code)
|
t.Fatalf("expected 404 for /debug/vars without WithExpvar, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithExpvar_Ugly_DoubleRegistrationDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("double WithExpvar panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Registering expvar twice should not panic.
|
||||||
|
engine, err := api.New(api.WithExpvar(), api.WithExpvar())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module dappco.re/go/core/io
|
|
||||||
|
|
||||||
go 1.26.0
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package io
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
// LocalFS provides simple local filesystem helpers used by the API module.
|
|
||||||
var Local localFS
|
|
||||||
|
|
||||||
type localFS struct{}
|
|
||||||
|
|
||||||
// EnsureDir creates the directory path if it does not already exist.
|
|
||||||
func (localFS) EnsureDir(path string) error {
|
|
||||||
if path == "" || path == "." {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return os.MkdirAll(path, 0o755)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes the named file, ignoring missing files.
|
|
||||||
func (localFS) Delete(path string) error {
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package log
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// E wraps an operation label and message in a conventional error.
|
|
||||||
// If err is non-nil, it is wrapped with %w.
|
|
||||||
func E(op, message string, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %s: %w", op, message, err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s: %s", op, message)
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module dappco.re/go/core/log
|
|
||||||
|
|
||||||
go 1.26.0
|
|
||||||
21
go.mod
21
go.mod
|
|
@ -3,9 +3,11 @@ module dappco.re/go/core/api
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core/io v0.1.7
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
dappco.re/go/core/log v0.0.4
|
dappco.re/go/core/io v0.2.0
|
||||||
dappco.re/go/core/cli v0.3.7
|
dappco.re/go/core/log v0.1.0
|
||||||
|
dappco.re/go/core/process v0.0.0-00010101000000-000000000000
|
||||||
|
forge.lthn.ai/core/cli v0.3.7
|
||||||
github.com/99designs/gqlgen v0.17.88
|
github.com/99designs/gqlgen v0.17.88
|
||||||
github.com/andybalholm/brotli v1.2.0
|
github.com/andybalholm/brotli v1.2.0
|
||||||
github.com/casbin/casbin/v2 v2.135.0
|
github.com/casbin/casbin/v2 v2.135.0
|
||||||
|
|
@ -38,10 +40,10 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.3.2 // indirect
|
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||||
dappco.re/go/core/i18n v0.1.7 // indirect
|
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
||||||
dappco.re/go/core/inference v0.1.7 // indirect
|
forge.lthn.ai/core/go-inference v0.1.7 // indirect
|
||||||
dappco.re/go/core/log v0.0.4 // indirect
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
|
@ -132,6 +134,7 @@ require (
|
||||||
replace (
|
replace (
|
||||||
dappco.re/go/core => ../go
|
dappco.re/go/core => ../go
|
||||||
dappco.re/go/core/i18n => ../go-i18n
|
dappco.re/go/core/i18n => ../go-i18n
|
||||||
dappco.re/go/core/io => ./go-io
|
dappco.re/go/core/io => ../go-io
|
||||||
dappco.re/go/core/log => ./go-log
|
dappco.re/go/core/log => ../go-log
|
||||||
|
dappco.re/go/core/process => ../go-process
|
||||||
)
|
)
|
||||||
|
|
|
||||||
100
graphql.go
100
graphql.go
|
|
@ -4,7 +4,6 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
"github.com/99designs/gqlgen/graphql/handler"
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
|
|
@ -22,114 +21,43 @@ type graphqlConfig struct {
|
||||||
playground bool
|
playground bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GraphQLConfig captures the configured GraphQL endpoint settings for an Engine.
|
|
||||||
//
|
|
||||||
// It is intentionally small and serialisable so callers can inspect the active
|
|
||||||
// GraphQL surface without reaching into the internal handler configuration.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := api.GraphQLConfig{Enabled: true, Path: "/graphql", Playground: true}
|
|
||||||
type GraphQLConfig struct {
|
|
||||||
Enabled bool
|
|
||||||
Path string
|
|
||||||
Playground bool
|
|
||||||
PlaygroundPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GraphQLConfig returns the currently configured GraphQL settings for the engine.
|
|
||||||
//
|
|
||||||
// The result snapshots the Engine state at call time and normalises any configured
|
|
||||||
// URL path using the same rules as the runtime handlers.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.GraphQLConfig()
|
|
||||||
func (e *Engine) GraphQLConfig() GraphQLConfig {
|
|
||||||
if e == nil {
|
|
||||||
return GraphQLConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := GraphQLConfig{
|
|
||||||
Enabled: e.graphql != nil,
|
|
||||||
Playground: e.graphql != nil && e.graphql.playground,
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.graphql != nil {
|
|
||||||
cfg.Path = normaliseGraphQLPath(e.graphql.path)
|
|
||||||
if e.graphql.playground {
|
|
||||||
cfg.PlaygroundPath = cfg.Path + "/playground"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// GraphQLOption configures a GraphQL endpoint.
|
// GraphQLOption configures a GraphQL endpoint.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// opts := []api.GraphQLOption{api.WithPlayground(), api.WithGraphQLPath("/gql")}
|
|
||||||
type GraphQLOption func(*graphqlConfig)
|
type GraphQLOption func(*graphqlConfig)
|
||||||
|
|
||||||
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
|
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithGraphQL(schema, api.WithPlayground())
|
|
||||||
func WithPlayground() GraphQLOption {
|
func WithPlayground() GraphQLOption {
|
||||||
return func(cfg *graphqlConfig) {
|
return func(config *graphqlConfig) {
|
||||||
cfg.playground = true
|
config.playground = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
|
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
|
||||||
// The default path is "/graphql".
|
// The default path is "/graphql".
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithGraphQL(schema, api.WithGraphQLPath("/gql"))
|
|
||||||
func WithGraphQLPath(path string) GraphQLOption {
|
func WithGraphQLPath(path string) GraphQLOption {
|
||||||
return func(cfg *graphqlConfig) {
|
return func(config *graphqlConfig) {
|
||||||
cfg.path = normaliseGraphQLPath(path)
|
config.path = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mountGraphQL registers the GraphQL handler and optional playground on the Gin engine.
|
// mountGraphQL registers the GraphQL handler and optional playground on the Gin engine.
|
||||||
func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) {
|
func mountGraphQL(router *gin.Engine, config *graphqlConfig) {
|
||||||
srv := handler.NewDefaultServer(cfg.schema)
|
graphqlServer := handler.NewDefaultServer(config.schema)
|
||||||
graphqlHandler := gin.WrapH(srv)
|
graphqlHandler := gin.WrapH(graphqlServer)
|
||||||
|
|
||||||
// Mount the GraphQL endpoint for all HTTP methods (POST for queries/mutations,
|
// Mount the GraphQL endpoint for all HTTP methods (POST for queries/mutations,
|
||||||
// GET for playground redirects and introspection).
|
// GET for playground redirects and introspection).
|
||||||
r.Any(cfg.path, graphqlHandler)
|
router.Any(config.path, graphqlHandler)
|
||||||
|
|
||||||
if cfg.playground {
|
if config.playground {
|
||||||
playgroundPath := cfg.path + "/playground"
|
playgroundPath := config.path + "/playground"
|
||||||
playgroundHandler := playground.Handler("GraphQL", cfg.path)
|
playgroundHandler := playground.Handler("GraphQL", config.path)
|
||||||
r.GET(playgroundPath, wrapHTTPHandler(playgroundHandler))
|
router.GET(playgroundPath, wrapHTTPHandler(playgroundHandler))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normaliseGraphQLPath coerces custom GraphQL paths into a stable form.
|
|
||||||
// The path always begins with a single slash and never ends with one.
|
|
||||||
func normaliseGraphQLPath(path string) string {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
return defaultGraphQLPath
|
|
||||||
}
|
|
||||||
|
|
||||||
path = "/" + strings.Trim(path, "/")
|
|
||||||
if path == "/" {
|
|
||||||
return defaultGraphQLPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function.
|
// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function.
|
||||||
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
|
func wrapHTTPHandler(handler http.Handler) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
h.ServeHTTP(c.Writer, c.Request)
|
handler.ServeHTTP(c.Writer, c.Request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEngine_GraphQLConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath(" /gql/ ")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.GraphQLConfig()
|
|
||||||
if !cfg.Enabled {
|
|
||||||
t.Fatal("expected GraphQL to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.Path != "/gql" {
|
|
||||||
t.Fatalf("expected GraphQL path /gql, got %q", cfg.Path)
|
|
||||||
}
|
|
||||||
if !cfg.Playground {
|
|
||||||
t.Fatal("expected GraphQL playground to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.PlaygroundPath != "/gql/playground" {
|
|
||||||
t.Fatalf("expected GraphQL playground path /gql/playground, got %q", cfg.PlaygroundPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_GraphQLConfig_Good_EmptyOnNilEngine(t *testing.T) {
|
|
||||||
var e *api.Engine
|
|
||||||
|
|
||||||
cfg := e.GraphQLConfig()
|
|
||||||
if cfg.Enabled || cfg.Path != "" || cfg.Playground || cfg.PlaygroundPath != "" {
|
|
||||||
t.Fatalf("expected zero-value GraphQL config, got %+v", cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -192,72 +192,6 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithGraphQL_Good_NormalisesCustomPath(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(" /gql/ "), api.WithPlayground()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := httptest.NewServer(e.Handler())
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
body := `{"query":"{ name }"}`
|
|
||||||
resp, err := http.Post(srv.URL+"/gql", "application/json", strings.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 at normalised /gql, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pgResp, err := http.Get(srv.URL + "/gql/playground")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("playground request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer pgResp.Body.Close()
|
|
||||||
|
|
||||||
if pgResp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 at normalised /gql/playground, got %d", pgResp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGraphQL_Good_DefaultPathWhenEmptyCustomPath(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(""), api.WithPlayground()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := httptest.NewServer(e.Handler())
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
body := `{"query":"{ name }"}`
|
|
||||||
resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 at default /graphql, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pgResp, err := http.Get(srv.URL + "/graphql/playground")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("playground request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer pgResp.Body.Close()
|
|
||||||
|
|
||||||
if pgResp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 at default /graphql/playground, got %d", pgResp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|
@ -298,3 +232,30 @@ func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
t.Fatalf("expected response containing name:test, got %q", string(respBody))
|
t.Fatalf("expected response containing name:test, got %q", string(respBody))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithGraphQL_Ugly_DoubleRegistrationDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("double WithGraphQL panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
schema := newTestSchema()
|
||||||
|
// Registering two GraphQL schemas with different paths must not panic.
|
||||||
|
engine, err := api.New(
|
||||||
|
api.WithGraphQL(schema, api.WithGraphQLPath("/graphql")),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
98
group.go
98
group.go
|
|
@ -2,18 +2,10 @@
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import "github.com/gin-gonic/gin"
|
||||||
"iter"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RouteGroup registers API routes onto a Gin router group.
|
// RouteGroup registers API routes onto a Gin router group.
|
||||||
// Subsystems implement this interface to declare their endpoints.
|
// Subsystems implement this interface to declare their endpoints.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// var g api.RouteGroup = &myGroup{}
|
|
||||||
type RouteGroup interface {
|
type RouteGroup interface {
|
||||||
// Name returns a human-readable identifier for the group.
|
// Name returns a human-readable identifier for the group.
|
||||||
Name() string
|
Name() string
|
||||||
|
|
@ -26,10 +18,6 @@ type RouteGroup interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// var sg api.StreamGroup = &myStreamGroup{}
|
|
||||||
type StreamGroup interface {
|
type StreamGroup interface {
|
||||||
// Channels returns the list of channel names this group streams on.
|
// Channels returns the list of channel names this group streams on.
|
||||||
Channels() []string
|
Channels() []string
|
||||||
|
|
@ -38,89 +26,19 @@ type StreamGroup interface {
|
||||||
// DescribableGroup extends RouteGroup with OpenAPI metadata.
|
// DescribableGroup extends RouteGroup with OpenAPI metadata.
|
||||||
// RouteGroups that implement this will have their endpoints
|
// RouteGroups that implement this will have their endpoints
|
||||||
// included in the generated OpenAPI specification.
|
// included in the generated OpenAPI specification.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// var dg api.DescribableGroup = &myDescribableGroup{}
|
|
||||||
type DescribableGroup interface {
|
type DescribableGroup interface {
|
||||||
RouteGroup
|
RouteGroup
|
||||||
// Describe returns endpoint descriptions for OpenAPI generation.
|
// Describe returns endpoint descriptions for OpenAPI generation.
|
||||||
Describe() []RouteDescription
|
Describe() []RouteDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
// DescribableGroupIter extends DescribableGroup with an iterator-based
|
|
||||||
// description source for callers that want to avoid slice allocation.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// var dg api.DescribableGroupIter = &myDescribableGroup{}
|
|
||||||
type DescribableGroupIter interface {
|
|
||||||
DescribableGroup
|
|
||||||
// DescribeIter returns endpoint descriptions for OpenAPI generation.
|
|
||||||
DescribeIter() iter.Seq[RouteDescription]
|
|
||||||
}
|
|
||||||
|
|
||||||
// RouteDescription describes a single endpoint for OpenAPI generation.
|
// RouteDescription describes a single endpoint for OpenAPI generation.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// rd := api.RouteDescription{
|
|
||||||
// Method: "POST",
|
|
||||||
// Path: "/users",
|
|
||||||
// Summary: "Create a user",
|
|
||||||
// Description: "Creates a new user account.",
|
|
||||||
// Tags: []string{"users"},
|
|
||||||
// StatusCode: 201,
|
|
||||||
// RequestBody: map[string]any{"type": "object"},
|
|
||||||
// Response: map[string]any{"type": "object"},
|
|
||||||
// }
|
|
||||||
type RouteDescription struct {
|
type RouteDescription struct {
|
||||||
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
||||||
Path string // Path relative to BasePath, e.g. "/generate"
|
Path string // Path relative to BasePath, e.g. "/generate"
|
||||||
Summary string // Short summary
|
Summary string // Short summary
|
||||||
Description string // Long description
|
Description string // Long description
|
||||||
Tags []string // OpenAPI tags for grouping
|
Tags []string // OpenAPI tags for grouping
|
||||||
// Hidden omits the route from generated documentation.
|
RequestBody map[string]any // JSON Schema for request body (nil for GET)
|
||||||
Hidden bool
|
Response map[string]any // JSON Schema for success response data
|
||||||
// Deprecated marks the operation as deprecated in OpenAPI.
|
|
||||||
Deprecated bool
|
|
||||||
// SunsetDate marks when a deprecated operation will be removed.
|
|
||||||
// Use YYYY-MM-DD or an RFC 7231 HTTP date string.
|
|
||||||
SunsetDate string
|
|
||||||
// Replacement points to the successor endpoint URL, when known.
|
|
||||||
Replacement string
|
|
||||||
// StatusCode is the documented 2xx success status code.
|
|
||||||
// Zero defaults to 200.
|
|
||||||
StatusCode int
|
|
||||||
// Security overrides the default bearerAuth requirement when non-nil.
|
|
||||||
// Use an empty, non-nil slice to mark the route as public.
|
|
||||||
Security []map[string][]string
|
|
||||||
Parameters []ParameterDescription
|
|
||||||
RequestBody map[string]any // JSON Schema for request body (nil for GET)
|
|
||||||
RequestExample any // Optional example payload for the request body.
|
|
||||||
Response map[string]any // JSON Schema for success response data
|
|
||||||
ResponseExample any // Optional example payload for the success response.
|
|
||||||
ResponseHeaders map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParameterDescription describes an OpenAPI parameter for a route.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// param := api.ParameterDescription{
|
|
||||||
// Name: "id",
|
|
||||||
// In: "path",
|
|
||||||
// Description: "User identifier",
|
|
||||||
// Required: true,
|
|
||||||
// Schema: map[string]any{"type": "string"},
|
|
||||||
// Example: "usr_123",
|
|
||||||
// }
|
|
||||||
type ParameterDescription struct {
|
|
||||||
Name string // Parameter name.
|
|
||||||
In string // Parameter location: path, query, header, or cookie.
|
|
||||||
Description string // Human-readable parameter description.
|
|
||||||
Required bool // Whether the parameter is required.
|
|
||||||
Deprecated bool // Whether the parameter is deprecated.
|
|
||||||
Schema map[string]any // JSON Schema for the parameter value.
|
|
||||||
Example any // Optional example value.
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,3 +224,28 @@ func TestDescribableGroup_Bad_NilSchemas(t *testing.T) {
|
||||||
t.Fatalf("expected nil Response, got %v", descs[0].Response)
|
t.Fatalf("expected nil Response, got %v", descs[0].Response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouteGroup_Ugly_EmptyBasePathDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("Register with empty BasePath panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// A group with an empty base path should mount at root without panicking.
|
||||||
|
engine, err := api.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
engine.Register(&stubGroup{})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
24
gzip_test.go
24
gzip_test.go
|
|
@ -131,3 +131,27 @@ func TestWithGzip_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
t.Fatal("expected X-Request-ID header from WithRequestID")
|
t.Fatal("expected X-Request-ID header from WithRequestID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithGzip_Ugly_NilBodyDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("gzip handler panicked on nil body: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
engine, err := api.New(api.WithGzip())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
143
i18n.go
143
i18n.go
|
|
@ -3,9 +3,6 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
@ -16,21 +13,7 @@ const i18nContextKey = "i18n.locale"
|
||||||
// i18nMessagesKey is the Gin context key for the message lookup map.
|
// i18nMessagesKey is the Gin context key for the message lookup map.
|
||||||
const i18nMessagesKey = "i18n.messages"
|
const i18nMessagesKey = "i18n.messages"
|
||||||
|
|
||||||
// i18nCatalogKey is the Gin context key for the full locale->message catalog.
|
|
||||||
const i18nCatalogKey = "i18n.catalog"
|
|
||||||
|
|
||||||
// i18nDefaultLocaleKey stores the configured default locale for fallback lookups.
|
|
||||||
const i18nDefaultLocaleKey = "i18n.default_locale"
|
|
||||||
|
|
||||||
// I18nConfig configures the internationalisation middleware.
|
// I18nConfig configures the internationalisation middleware.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := api.I18nConfig{
|
|
||||||
// DefaultLocale: "en",
|
|
||||||
// Supported: []string{"en", "fr"},
|
|
||||||
// Messages: map[string]map[string]string{"fr": {"greeting": "Bonjour"}},
|
|
||||||
// }
|
|
||||||
type I18nConfig struct {
|
type I18nConfig struct {
|
||||||
// DefaultLocale is the fallback locale when the Accept-Language header
|
// DefaultLocale is the fallback locale when the Accept-Language header
|
||||||
// is absent or does not match any supported locale. Defaults to "en".
|
// is absent or does not match any supported locale. Defaults to "en".
|
||||||
|
|
@ -47,32 +30,11 @@ type I18nConfig struct {
|
||||||
Messages map[string]map[string]string
|
Messages map[string]map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// I18nConfig returns the configured locale and message catalogue settings for
|
|
||||||
// the engine.
|
|
||||||
//
|
|
||||||
// The result snapshots the Engine state at call time and clones slices/maps so
|
|
||||||
// callers can safely reuse or modify the returned value.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.I18nConfig()
|
|
||||||
func (e *Engine) I18nConfig() I18nConfig {
|
|
||||||
if e == nil {
|
|
||||||
return I18nConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneI18nConfig(e.i18nConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithI18n adds Accept-Language header parsing and locale detection middleware.
|
// WithI18n adds Accept-Language header parsing and locale detection middleware.
|
||||||
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
|
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
|
||||||
// with quality weighting support. The detected locale is stored in the Gin
|
// with quality weighting support. The detected locale is stored in the Gin
|
||||||
// context and can be retrieved by handlers via GetLocale().
|
// context and can be retrieved by handlers via GetLocale().
|
||||||
//
|
//
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithI18n(api.I18nConfig{Supported: []string{"en", "fr"}}))
|
|
||||||
//
|
|
||||||
// If messages are configured, handlers can look up localised strings via
|
// If messages are configured, handlers can look up localised strings via
|
||||||
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
|
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
|
||||||
// can replace the message map later.
|
// can replace the message map later.
|
||||||
|
|
@ -88,23 +50,21 @@ func WithI18n(cfg ...I18nConfig) Option {
|
||||||
|
|
||||||
// Build the language.Matcher from supported locales.
|
// Build the language.Matcher from supported locales.
|
||||||
tags := []language.Tag{language.Make(config.DefaultLocale)}
|
tags := []language.Tag{language.Make(config.DefaultLocale)}
|
||||||
for _, s := range config.Supported {
|
for _, supportedLocale := range config.Supported {
|
||||||
tag := language.Make(s)
|
tag := language.Make(supportedLocale)
|
||||||
// Avoid duplicating the default if it also appears in Supported.
|
// Avoid duplicating the default if it also appears in Supported.
|
||||||
if tag != tags[0] {
|
if tag != tags[0] {
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot := cloneI18nConfig(config)
|
|
||||||
e.i18nConfig = snapshot
|
|
||||||
matcher := language.NewMatcher(tags)
|
matcher := language.NewMatcher(tags)
|
||||||
|
|
||||||
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, snapshot))
|
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
|
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
|
||||||
// it against supported locales, and stores the resolved BCP 47 tag in the context.
|
// it against supported locales, and stores the result in the context.
|
||||||
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
accept := c.GetHeader("Accept-Language")
|
accept := c.GetHeader("Accept-Language")
|
||||||
|
|
@ -115,17 +75,19 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
} else {
|
} else {
|
||||||
tags, _, _ := language.ParseAcceptLanguage(accept)
|
tags, _, _ := language.ParseAcceptLanguage(accept)
|
||||||
tag, _, _ := matcher.Match(tags...)
|
tag, _, _ := matcher.Match(tags...)
|
||||||
locale = tag.String()
|
base, _ := tag.Base()
|
||||||
|
locale = base.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(i18nContextKey, locale)
|
c.Set(i18nContextKey, locale)
|
||||||
c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale)
|
|
||||||
|
|
||||||
// Attach the message map for this locale if messages are configured.
|
// Attach the message map for this locale if messages are configured.
|
||||||
if cfg.Messages != nil {
|
if cfg.Messages != nil {
|
||||||
c.Set(i18nCatalogKey, cfg.Messages)
|
|
||||||
if msgs, ok := cfg.Messages[locale]; ok {
|
if msgs, ok := cfg.Messages[locale]; ok {
|
||||||
c.Set(i18nMessagesKey, msgs)
|
c.Set(i18nMessagesKey, msgs)
|
||||||
|
} else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok {
|
||||||
|
// Fall back to default locale messages.
|
||||||
|
c.Set(i18nMessagesKey, msgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,10 +97,6 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
|
|
||||||
// GetLocale returns the detected locale for the current request.
|
// GetLocale returns the detected locale for the current request.
|
||||||
// Returns "en" if the i18n middleware was not applied.
|
// Returns "en" if the i18n middleware was not applied.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// locale := api.GetLocale(c)
|
|
||||||
func GetLocale(c *gin.Context) string {
|
func GetLocale(c *gin.Context) string {
|
||||||
if v, ok := c.Get(i18nContextKey); ok {
|
if v, ok := c.Get(i18nContextKey); ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
|
|
@ -151,10 +109,6 @@ func GetLocale(c *gin.Context) string {
|
||||||
// GetMessage looks up a localised message by key for the current request.
|
// GetMessage looks up a localised message by key for the current request.
|
||||||
// Returns the message string and true if found, or empty string and false
|
// Returns the message string and true if found, or empty string and false
|
||||||
// if the key does not exist or the i18n middleware was not applied.
|
// if the key does not exist or the i18n middleware was not applied.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// msg, ok := api.GetMessage(c, "greeting")
|
|
||||||
func GetMessage(c *gin.Context, key string) (string, bool) {
|
func GetMessage(c *gin.Context, key string) (string, bool) {
|
||||||
if v, ok := c.Get(i18nMessagesKey); ok {
|
if v, ok := c.Get(i18nMessagesKey); ok {
|
||||||
if msgs, ok := v.(map[string]string); ok {
|
if msgs, ok := v.(map[string]string); ok {
|
||||||
|
|
@ -163,84 +117,5 @@ func GetMessage(c *gin.Context, key string) (string, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
catalog, _ := c.Get(i18nCatalogKey)
|
|
||||||
msgsByLocale, _ := catalog.(map[string]map[string]string)
|
|
||||||
if len(msgsByLocale) == 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
locales := localeFallbacks(GetLocale(c))
|
|
||||||
if defaultLocale, ok := c.Get(i18nDefaultLocaleKey); ok {
|
|
||||||
if fallback, ok := defaultLocale.(string); ok && fallback != "" {
|
|
||||||
locales = append(locales, localeFallbacks(fallback)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := make(map[string]struct{}, len(locales))
|
|
||||||
for _, locale := range locales {
|
|
||||||
if locale == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[locale]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[locale] = struct{}{}
|
|
||||||
if msgs, ok := msgsByLocale[locale]; ok {
|
|
||||||
if msg, ok := msgs[key]; ok {
|
|
||||||
return msg, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// localeFallbacks returns the locale and its parent tags in order from
|
|
||||||
// most specific to least specific. For example, "fr-CA" yields
|
|
||||||
// ["fr-CA", "fr"] and "zh-Hant-TW" yields ["zh-Hant-TW", "zh-Hant", "zh"].
|
|
||||||
func localeFallbacks(locale string) []string {
|
|
||||||
locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
|
|
||||||
if locale == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(locale, "-")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return []string{locale}
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbacks := make([]string, 0, len(parts))
|
|
||||||
for i := len(parts); i >= 1; i-- {
|
|
||||||
fallbacks = append(fallbacks, strings.Join(parts[:i], "-"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneI18nConfig(cfg I18nConfig) I18nConfig {
|
|
||||||
out := cfg
|
|
||||||
out.Supported = slices.Clone(cfg.Supported)
|
|
||||||
out.Messages = cloneI18nMessages(cfg.Messages)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneI18nMessages(messages map[string]map[string]string) map[string]map[string]string {
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]map[string]string, len(messages))
|
|
||||||
for locale, msgs := range messages {
|
|
||||||
if len(msgs) == 0 {
|
|
||||||
out[locale] = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(msgs))
|
|
||||||
for key, value := range msgs {
|
|
||||||
cloned[key] = value
|
|
||||||
}
|
|
||||||
out[locale] = cloned
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
|
||||||
155
i18n_test.go
155
i18n_test.go
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
@ -134,33 +133,6 @@ func TestWithI18n_Good_QualityWeighting(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithI18n_Good_PreservesMatchedLocaleTag(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en",
|
|
||||||
Supported: []string{"en", "fr", "fr-CA"},
|
|
||||||
}))
|
|
||||||
e.Register(&i18nTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil)
|
|
||||||
req.Header.Set("Accept-Language", "fr-CA, fr;q=0.8")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp i18nLocaleResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Data["locale"] != "fr-CA" {
|
|
||||||
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data["locale"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
e, _ := api.New(
|
e, _ := api.New(
|
||||||
|
|
@ -253,121 +225,30 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) {
|
func TestWithI18n_Ugly_MalformedAcceptLanguageDoesNotPanic(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
e, _ := api.New(api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en",
|
|
||||||
Supported: []string{"en", "fr", "fr-CA"},
|
|
||||||
Messages: map[string]map[string]string{
|
|
||||||
"en": {"greeting": "Hello"},
|
|
||||||
"fr": {"greeting": "Bonjour"},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
e.Register(&i18nTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
defer func() {
|
||||||
w := httptest.NewRecorder()
|
if r := recover(); r != nil {
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
|
t.Fatalf("i18n middleware panicked on malformed Accept-Language: %v", r)
|
||||||
req.Header.Set("Accept-Language", "fr-CA")
|
}
|
||||||
h.ServeHTTP(w, req)
|
}()
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
engine, err := api.New(api.WithI18n(api.I18nConfig{
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp i18nMessageResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Data.Locale != "fr-CA" {
|
|
||||||
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data.Locale)
|
|
||||||
}
|
|
||||||
if resp.Data.Message != "Bonjour" {
|
|
||||||
t.Fatalf("expected fallback message=%q, got %q", "Bonjour", resp.Data.Message)
|
|
||||||
}
|
|
||||||
if !resp.Data.Found {
|
|
||||||
t.Fatal("expected found=true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_I18nConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
|
||||||
e, _ := api.New(api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en",
|
DefaultLocale: "en",
|
||||||
Supported: []string{"en", "fr"},
|
Supported: []string{"en", "fr"},
|
||||||
Messages: map[string]map[string]string{
|
|
||||||
"en": {"greeting": "Hello"},
|
|
||||||
"fr": {"greeting": "Bonjour"},
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
snap := e.I18nConfig()
|
recorder := httptest.NewRecorder()
|
||||||
if snap.DefaultLocale != "en" {
|
// Gibberish Accept-Language should fall back to default, not panic.
|
||||||
t.Fatalf("expected default locale en, got %q", snap.DefaultLocale)
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
}
|
request.Header.Set("Accept-Language", ";;;invalid;;;")
|
||||||
if !slices.Equal(snap.Supported, []string{"en", "fr"}) {
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
t.Fatalf("expected supported locales [en fr], got %v", snap.Supported)
|
|
||||||
}
|
if recorder.Code != http.StatusOK {
|
||||||
if snap.Messages["fr"]["greeting"] != "Bonjour" {
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
t.Fatalf("expected cloned French greeting, got %q", snap.Messages["fr"]["greeting"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_I18nConfig_Good_ClonesMutableInputs(t *testing.T) {
|
|
||||||
supported := []string{"en", "fr"}
|
|
||||||
messages := map[string]map[string]string{
|
|
||||||
"en": {"greeting": "Hello"},
|
|
||||||
"fr": {"greeting": "Bonjour"},
|
|
||||||
}
|
|
||||||
|
|
||||||
e, _ := api.New(api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en",
|
|
||||||
Supported: supported,
|
|
||||||
Messages: messages,
|
|
||||||
}))
|
|
||||||
|
|
||||||
supported[0] = "de"
|
|
||||||
messages["fr"]["greeting"] = "Salut"
|
|
||||||
|
|
||||||
snap := e.I18nConfig()
|
|
||||||
if !slices.Equal(snap.Supported, []string{"en", "fr"}) {
|
|
||||||
t.Fatalf("expected engine supported locales to be cloned, got %v", snap.Supported)
|
|
||||||
}
|
|
||||||
if snap.Messages["fr"]["greeting"] != "Bonjour" {
|
|
||||||
t.Fatalf("expected engine message catalogue to be cloned, got %q", snap.Messages["fr"]["greeting"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithI18n_Good_SnapshotsMutableInputs(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
messages := map[string]map[string]string{
|
|
||||||
"en": {"greeting": "Hello"},
|
|
||||||
"fr": {"greeting": "Bonjour"},
|
|
||||||
}
|
|
||||||
|
|
||||||
e, _ := api.New(api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en",
|
|
||||||
Supported: []string{"en", "fr"},
|
|
||||||
Messages: messages,
|
|
||||||
}))
|
|
||||||
e.Register(&i18nTestGroup{})
|
|
||||||
|
|
||||||
messages["fr"]["greeting"] = "Salut"
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
|
|
||||||
req.Header.Set("Accept-Language", "fr")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp i18nMessageResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Data.Message != "Bonjour" {
|
|
||||||
t.Fatalf("expected cloned greeting %q, got %q", "Bonjour", resp.Data.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,3 +178,27 @@ func TestWithLocation_Good_BothHeadersCombined(t *testing.T) {
|
||||||
t.Fatalf("expected host=%q, got %q", "secure.example.com", resp.Data["host"])
|
t.Fatalf("expected host=%q, got %q", "secure.example.com", resp.Data["host"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithLocation_Ugly_MissingHeadersDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("location middleware panicked on missing headers: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
engine, err := api.New(api.WithLocation())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
// No X-Forwarded-Proto or X-Forwarded-Host headers — should not panic.
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
119
middleware.go
119
middleware.go
|
|
@ -5,44 +5,20 @@ package api
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestIDContextKey is the Gin context key used by requestIDMiddleware.
|
|
||||||
const requestIDContextKey = "request_id"
|
|
||||||
|
|
||||||
// requestStartContextKey stores when the request began so handlers can
|
|
||||||
// calculate elapsed duration for response metadata.
|
|
||||||
const requestStartContextKey = "request_start"
|
|
||||||
|
|
||||||
// recoveryMiddleware converts panics into a standard JSON error envelope.
|
|
||||||
// This keeps internal failures consistent with the rest of the framework
|
|
||||||
// and avoids Gin's default plain-text 500 response.
|
|
||||||
func recoveryMiddleware() gin.HandlerFunc {
|
|
||||||
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
|
||||||
fmt.Fprintf(gin.DefaultErrorWriter, "[Recovery] panic recovered: %v\n", recovered)
|
|
||||||
debug.PrintStack()
|
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, Fail(
|
|
||||||
"internal_server_error",
|
|
||||||
"Internal server error",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
|
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
|
||||||
// Requests to paths in the skip list are allowed through without authentication.
|
// Requests to paths in the skip list are allowed through without authentication.
|
||||||
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
|
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
|
||||||
func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Check whether the request path should bypass authentication.
|
// Check whether the request path should bypass authentication.
|
||||||
for _, path := range skip() {
|
for _, path := range skip {
|
||||||
if isPublicPath(c.Request.URL.Path, path) {
|
if core.HasPrefix(c.Request.URL.Path, path) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -54,8 +30,8 @@ func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(header, " ", 2)
|
parts := core.SplitN(header, " ", 2)
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token {
|
if len(parts) != 2 || core.Lower(parts[0]) != "bearer" || parts[1] != token {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
|
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -64,37 +40,11 @@ func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPublicPath reports whether requestPath should bypass auth for publicPath.
|
|
||||||
// It matches the exact path and any nested subpath, but not sibling prefixes
|
|
||||||
// such as /swaggerx when the public path is /swagger.
|
|
||||||
func isPublicPath(requestPath, publicPath string) bool {
|
|
||||||
if publicPath == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized := strings.TrimRight(publicPath, "/")
|
|
||||||
if normalized == "" {
|
|
||||||
normalized = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestPath == normalized {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if normalized == "/" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.HasPrefix(requestPath, normalized+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestIDMiddleware ensures every response carries an X-Request-ID header.
|
// requestIDMiddleware ensures every response carries an X-Request-ID header.
|
||||||
// If the client sends one, it is preserved; otherwise a random 16-byte hex
|
// If the client sends one, it is preserved; otherwise a random 16-byte hex
|
||||||
// string is generated. The ID is also stored in the Gin context as "request_id".
|
// string is generated. The ID is also stored in the Gin context as "request_id".
|
||||||
func requestIDMiddleware() gin.HandlerFunc {
|
func requestIDMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Set(requestStartContextKey, time.Now())
|
|
||||||
|
|
||||||
id := c.GetHeader("X-Request-ID")
|
id := c.GetHeader("X-Request-ID")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
|
|
@ -102,63 +52,8 @@ func requestIDMiddleware() gin.HandlerFunc {
|
||||||
id = hex.EncodeToString(b)
|
id = hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(requestIDContextKey, id)
|
c.Set("request_id", id)
|
||||||
c.Header("X-Request-ID", id)
|
c.Header("X-Request-ID", id)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRequestID returns the request ID assigned by requestIDMiddleware.
|
|
||||||
// Returns an empty string when the middleware was not applied.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// id := api.GetRequestID(c)
|
|
||||||
func GetRequestID(c *gin.Context) string {
|
|
||||||
if v, ok := c.Get(requestIDContextKey); ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRequestDuration returns the elapsed time since requestIDMiddleware started
|
|
||||||
// handling the request. Returns 0 when the middleware was not applied.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// d := api.GetRequestDuration(c)
|
|
||||||
func GetRequestDuration(c *gin.Context) time.Duration {
|
|
||||||
if v, ok := c.Get(requestStartContextKey); ok {
|
|
||||||
if started, ok := v.(time.Time); ok && !started.IsZero() {
|
|
||||||
return time.Since(started)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRequestMeta returns request metadata collected by requestIDMiddleware.
|
|
||||||
// The returned meta includes the request ID and elapsed duration when
|
|
||||||
// available. It returns nil when neither value is available.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// meta := api.GetRequestMeta(c)
|
|
||||||
func GetRequestMeta(c *gin.Context) *Meta {
|
|
||||||
meta := &Meta{}
|
|
||||||
|
|
||||||
if id := GetRequestID(c); id != "" {
|
|
||||||
meta.RequestID = id
|
|
||||||
}
|
|
||||||
|
|
||||||
if duration := GetRequestDuration(c); duration > 0 {
|
|
||||||
meta.Duration = duration.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
if meta.RequestID == "" && meta.Duration == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@
|
||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
api "dappco.re/go/core/api"
|
||||||
|
|
@ -27,75 +26,6 @@ func (m *mwTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type swaggerLikeGroup struct{}
|
|
||||||
|
|
||||||
func (g *swaggerLikeGroup) Name() string { return "swagger-like" }
|
|
||||||
func (g *swaggerLikeGroup) BasePath() string { return "/swaggerx" }
|
|
||||||
func (g *swaggerLikeGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/secret", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("classified"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type requestIDTestGroup struct {
|
|
||||||
gotID *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g requestIDTestGroup) Name() string { return "request-id" }
|
|
||||||
func (g requestIDTestGroup) BasePath() string { return "/v1" }
|
|
||||||
func (g requestIDTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/secret", func(c *gin.Context) {
|
|
||||||
*g.gotID = api.GetRequestID(c)
|
|
||||||
c.JSON(http.StatusOK, api.OK("classified"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type requestMetaTestGroup struct{}
|
|
||||||
|
|
||||||
func (g requestMetaTestGroup) Name() string { return "request-meta" }
|
|
||||||
func (g requestMetaTestGroup) BasePath() string { return "/v1" }
|
|
||||||
func (g requestMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/meta", func(c *gin.Context) {
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
resp := api.AttachRequestMeta(c, api.Paginated("classified", 1, 25, 100))
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type autoResponseMetaTestGroup struct{}
|
|
||||||
|
|
||||||
func (g autoResponseMetaTestGroup) Name() string { return "auto-response-meta" }
|
|
||||||
func (g autoResponseMetaTestGroup) BasePath() string { return "/v1" }
|
|
||||||
func (g autoResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/meta", func(c *gin.Context) {
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
c.JSON(http.StatusOK, api.Paginated("classified", 1, 25, 100))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type autoErrorResponseMetaTestGroup struct{}
|
|
||||||
|
|
||||||
func (g autoErrorResponseMetaTestGroup) Name() string { return "auto-error-response-meta" }
|
|
||||||
func (g autoErrorResponseMetaTestGroup) BasePath() string { return "/v1" }
|
|
||||||
func (g autoErrorResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/error", func(c *gin.Context) {
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
c.JSON(http.StatusBadRequest, api.Fail("bad_request", "request failed"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type plusJSONResponseMetaTestGroup struct{}
|
|
||||||
|
|
||||||
func (g plusJSONResponseMetaTestGroup) Name() string { return "plus-json-response-meta" }
|
|
||||||
func (g plusJSONResponseMetaTestGroup) BasePath() string { return "/v1" }
|
|
||||||
func (g plusJSONResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/plus-json", func(c *gin.Context) {
|
|
||||||
c.Header("Content-Type", "application/problem+json")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
_, _ = c.Writer.Write([]byte(`{"success":true,"data":"ok"}`))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bearer auth ─────────────────────────────────────────────────────────
|
// ── Bearer auth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
||||||
|
|
@ -113,8 +43,8 @@ func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp api.Response[any]
|
var resp api.Response[any]
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
t.Fatalf("unmarshal error: %v", result.Value)
|
||||||
}
|
}
|
||||||
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
||||||
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
|
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
|
||||||
|
|
@ -137,8 +67,8 @@ func TestBearerAuth_Bad_WrongToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp api.Response[any]
|
var resp api.Response[any]
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
t.Fatalf("unmarshal error: %v", result.Value)
|
||||||
}
|
}
|
||||||
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
||||||
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
|
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
|
||||||
|
|
@ -161,8 +91,8 @@ func TestBearerAuth_Good_CorrectToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp api.Response[string]
|
var resp api.Response[string]
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
t.Fatalf("unmarshal error: %v", result.Value)
|
||||||
}
|
}
|
||||||
if resp.Data != "classified" {
|
if resp.Data != "classified" {
|
||||||
t.Fatalf("expected Data=%q, got %q", "classified", resp.Data)
|
t.Fatalf("expected Data=%q, got %q", "classified", resp.Data)
|
||||||
|
|
@ -184,21 +114,6 @@ func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBearerAuth_Bad_SimilarPrefixDoesNotBypassAuth(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithBearerAuth("s3cret"))
|
|
||||||
e.Register(&swaggerLikeGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/swaggerx/secret", nil)
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("expected 401 for /swaggerx/secret, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request ID ──────────────────────────────────────────────────────────
|
// ── Request ID ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
|
func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
|
||||||
|
|
@ -236,176 +151,6 @@ func TestRequestID_Good_PreservesClientID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestID_Good_ContextAccessor(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRequestID())
|
|
||||||
|
|
||||||
var gotID string
|
|
||||||
e.Register(requestIDTestGroup{gotID: &gotID})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
|
|
||||||
req.Header.Set("X-Request-ID", "client-id-xyz")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if gotID == "" {
|
|
||||||
t.Fatal("expected GetRequestID to return the request ID inside the handler")
|
|
||||||
}
|
|
||||||
if gotID != "client-id-xyz" {
|
|
||||||
t.Fatalf("expected GetRequestID=%q, got %q", "client-id-xyz", gotID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestID_Good_RequestMetaHelper(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRequestID())
|
|
||||||
e.Register(requestMetaTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
|
|
||||||
req.Header.Set("X-Request-ID", "client-id-meta")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[string]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Meta == nil {
|
|
||||||
t.Fatal("expected Meta to be present")
|
|
||||||
}
|
|
||||||
if resp.Meta.RequestID != "client-id-meta" {
|
|
||||||
t.Fatalf("expected request_id=%q, got %q", "client-id-meta", resp.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected duration to be populated")
|
|
||||||
}
|
|
||||||
if resp.Meta.Page != 1 || resp.Meta.PerPage != 25 || resp.Meta.Total != 100 {
|
|
||||||
t.Fatalf("expected pagination metadata to be preserved, got %+v", resp.Meta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(
|
|
||||||
api.WithRequestID(),
|
|
||||||
api.WithResponseMeta(),
|
|
||||||
)
|
|
||||||
e.Register(autoResponseMetaTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
|
|
||||||
req.Header.Set("X-Request-ID", "client-id-auto-meta")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[string]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Meta == nil {
|
|
||||||
t.Fatal("expected Meta to be present")
|
|
||||||
}
|
|
||||||
if resp.Meta.RequestID != "client-id-auto-meta" {
|
|
||||||
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-meta", resp.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected duration to be populated")
|
|
||||||
}
|
|
||||||
if resp.Meta.Page != 1 || resp.Meta.PerPage != 25 || resp.Meta.Total != 100 {
|
|
||||||
t.Fatalf("expected pagination metadata to be preserved, got %+v", resp.Meta)
|
|
||||||
}
|
|
||||||
if got := w.Header().Get("X-Request-ID"); got != "client-id-auto-meta" {
|
|
||||||
t.Fatalf("expected response header X-Request-ID=%q, got %q", "client-id-auto-meta", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseMeta_Good_AttachesMetaToErrorResponses(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(
|
|
||||||
api.WithRequestID(),
|
|
||||||
api.WithResponseMeta(),
|
|
||||||
)
|
|
||||||
e.Register(autoErrorResponseMetaTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/v1/error", nil)
|
|
||||||
req.Header.Set("X-Request-ID", "client-id-auto-error-meta")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[string]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Meta == nil {
|
|
||||||
t.Fatal("expected Meta to be present")
|
|
||||||
}
|
|
||||||
if resp.Meta.RequestID != "client-id-auto-error-meta" {
|
|
||||||
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-error-meta", resp.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected duration to be populated")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "bad_request" {
|
|
||||||
t.Fatalf("expected bad_request error, got %+v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseMeta_Good_AttachesMetaToPlusJSONContentType(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(
|
|
||||||
api.WithRequestID(),
|
|
||||||
api.WithResponseMeta(),
|
|
||||||
)
|
|
||||||
e.Register(plusJSONResponseMetaTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/v1/plus-json", nil)
|
|
||||||
req.Header.Set("X-Request-ID", "client-id-plus-json-meta")
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := w.Header().Get("Content-Type"); got != "application/problem+json" {
|
|
||||||
t.Fatalf("expected Content-Type to be preserved, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[string]
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Meta == nil {
|
|
||||||
t.Fatal("expected Meta to be present")
|
|
||||||
}
|
|
||||||
if resp.Meta.RequestID != "client-id-plus-json-meta" {
|
|
||||||
t.Fatalf("expected request_id=%q, got %q", "client-id-plus-json-meta", resp.Meta.RequestID)
|
|
||||||
}
|
|
||||||
if resp.Meta.Duration == "" {
|
|
||||||
t.Fatal("expected duration to be populated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CORS ────────────────────────────────────────────────────────────────
|
// ── CORS ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
|
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
|
||||||
|
|
@ -473,3 +218,29 @@ func TestCORS_Bad_DisallowedOrigin(t *testing.T) {
|
||||||
t.Fatalf("expected no Access-Control-Allow-Origin for disallowed origin, got %q", origin)
|
t.Fatalf("expected no Access-Control-Allow-Origin for disallowed origin, got %q", origin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth_Ugly_MalformedAuthHeaderDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("bearerAuth panicked on malformed header: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
engine, err := api.New(api.WithBearerAuth("secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
engine.Register(&mwTestGroup{})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
// Only one word — no space — should return 401, not panic.
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
|
||||||
|
request.Header.Set("Authorization", "BearerNOSPACE")
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,26 @@ package api_test
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEngine_GroupsIter(t *testing.T) {
|
type streamGroupStub struct {
|
||||||
e, _ := api.New()
|
healthGroup
|
||||||
g1 := &healthGroup{}
|
channels []string
|
||||||
e.Register(g1)
|
}
|
||||||
|
|
||||||
|
func (s *streamGroupStub) Channels() []string { return s.channels }
|
||||||
|
|
||||||
|
// ── GroupsIter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestModernization_GroupsIter_Good(t *testing.T) {
|
||||||
|
engine, _ := api.New()
|
||||||
|
engine.Register(&healthGroup{})
|
||||||
|
|
||||||
var groups []api.RouteGroup
|
var groups []api.RouteGroup
|
||||||
for g := range e.GroupsIter() {
|
for group := range engine.GroupsIter() {
|
||||||
groups = append(groups, g)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(groups) != 1 {
|
if len(groups) != 1 {
|
||||||
|
|
@ -28,45 +35,42 @@ func TestEngine_GroupsIter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEngine_GroupsIter_Good_SnapshotsCurrentGroups(t *testing.T) {
|
func TestModernization_GroupsIter_Bad(t *testing.T) {
|
||||||
e, _ := api.New()
|
engine, _ := api.New()
|
||||||
g1 := &healthGroup{}
|
// No groups registered — iterator should yield nothing.
|
||||||
g2 := &stubGroup{}
|
|
||||||
e.Register(g1)
|
|
||||||
|
|
||||||
iter := e.GroupsIter()
|
|
||||||
e.Register(g2)
|
|
||||||
|
|
||||||
var groups []api.RouteGroup
|
var groups []api.RouteGroup
|
||||||
for g := range iter {
|
for group := range engine.GroupsIter() {
|
||||||
groups = append(groups, g)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
|
if len(groups) != 0 {
|
||||||
if len(groups) != 1 {
|
t.Fatalf("expected 0 groups with no registration, got %d", len(groups))
|
||||||
t.Fatalf("expected iterator snapshot to contain 1 group, got %d", len(groups))
|
|
||||||
}
|
|
||||||
if groups[0].Name() != "health-extra" {
|
|
||||||
t.Fatalf("expected snapshot to preserve original group, got %q", groups[0].Name())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type streamGroupStub struct {
|
func TestModernization_GroupsIter_Ugly(t *testing.T) {
|
||||||
healthGroup
|
defer func() {
|
||||||
channels []string
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("GroupsIter on nil groups panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
engine, _ := api.New()
|
||||||
|
// Iterating immediately without any Register call must not panic.
|
||||||
|
for range engine.GroupsIter() {
|
||||||
|
t.Fatal("expected no iterations")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *streamGroupStub) Channels() []string { return s.channels }
|
// ── ChannelsIter ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestEngine_ChannelsIter(t *testing.T) {
|
func TestModernization_ChannelsIter_Good(t *testing.T) {
|
||||||
e, _ := api.New()
|
engine, _ := api.New()
|
||||||
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
|
engine.Register(&streamGroupStub{channels: []string{"ch1", "ch2"}})
|
||||||
g2 := &streamGroupStub{channels: []string{"ch3"}}
|
engine.Register(&streamGroupStub{channels: []string{"ch3"}})
|
||||||
e.Register(g1)
|
|
||||||
e.Register(g2)
|
|
||||||
|
|
||||||
var channels []string
|
var channels []string
|
||||||
for ch := range e.ChannelsIter() {
|
for channelName := range engine.ChannelsIter() {
|
||||||
channels = append(channels, ch)
|
channels = append(channels, channelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"ch1", "ch2", "ch3"}
|
expected := []string{"ch1", "ch2", "ch3"}
|
||||||
|
|
@ -75,270 +79,134 @@ func TestEngine_ChannelsIter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEngine_ChannelsIter_Good_SnapshotsCurrentChannels(t *testing.T) {
|
func TestModernization_ChannelsIter_Bad(t *testing.T) {
|
||||||
e, _ := api.New()
|
engine, _ := api.New()
|
||||||
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
|
// Register a group that has no Channels() — ChannelsIter must skip it.
|
||||||
g2 := &streamGroupStub{channels: []string{"ch3"}}
|
engine.Register(&healthGroup{})
|
||||||
e.Register(g1)
|
|
||||||
|
|
||||||
iter := e.ChannelsIter()
|
|
||||||
e.Register(g2)
|
|
||||||
|
|
||||||
var channels []string
|
var channels []string
|
||||||
for ch := range iter {
|
for channelName := range engine.ChannelsIter() {
|
||||||
channels = append(channels, ch)
|
channels = append(channels, channelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"ch1", "ch2"}
|
if len(channels) != 0 {
|
||||||
if !slices.Equal(channels, expected) {
|
t.Fatalf("expected 0 channels for non-StreamGroup, got %v", channels)
|
||||||
t.Fatalf("expected snapshot channels %v, got %v", expected, channels)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEngine_CacheConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
func TestModernization_ChannelsIter_Ugly(t *testing.T) {
|
||||||
e, _ := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024))
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("ChannelsIter panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
cfg := e.CacheConfig()
|
engine, _ := api.New()
|
||||||
|
// Group with empty channel list must not panic during iteration.
|
||||||
if !cfg.Enabled {
|
engine.Register(&streamGroupStub{channels: []string{}})
|
||||||
t.Fatal("expected cache config to be enabled")
|
for range engine.ChannelsIter() {
|
||||||
}
|
t.Fatal("expected no iterations for empty channel list")
|
||||||
if cfg.TTL != 5*time.Minute {
|
|
||||||
t.Fatalf("expected TTL %v, got %v", 5*time.Minute, cfg.TTL)
|
|
||||||
}
|
|
||||||
if cfg.MaxEntries != 10 {
|
|
||||||
t.Fatalf("expected MaxEntries 10, got %d", cfg.MaxEntries)
|
|
||||||
}
|
|
||||||
if cfg.MaxBytes != 1024 {
|
|
||||||
t.Fatalf("expected MaxBytes 1024, got %d", cfg.MaxBytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
// ── ToolBridge iterators ──────────────────────────────────────────────
|
||||||
broker := api.NewSSEBroker()
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger("Runtime API", "Runtime snapshot", "1.2.3"),
|
|
||||||
api.WithSwaggerPath("/docs"),
|
|
||||||
api.WithCacheLimits(5*time.Minute, 10, 1024),
|
|
||||||
api.WithGraphQL(newTestSchema(), api.WithPlayground()),
|
|
||||||
api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en-GB",
|
|
||||||
Supported: []string{"en-GB", "fr"},
|
|
||||||
}),
|
|
||||||
api.WithWSPath("/socket"),
|
|
||||||
api.WithSSE(broker),
|
|
||||||
api.WithSSEPath("/events"),
|
|
||||||
api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
Issuer: "https://auth.example.com",
|
|
||||||
ClientID: "runtime-client",
|
|
||||||
TrustedProxy: true,
|
|
||||||
PublicPaths: []string{"/public", "/docs"},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.RuntimeConfig()
|
func TestModernization_ToolBridgeIterators_Good(t *testing.T) {
|
||||||
|
bridge := api.NewToolBridge("/tools")
|
||||||
|
bridge.Add(api.ToolDescriptor{Name: "test", Group: "g1"}, nil)
|
||||||
|
|
||||||
if !cfg.Swagger.Enabled {
|
|
||||||
t.Fatal("expected swagger snapshot to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.Swagger.Path != "/docs" {
|
|
||||||
t.Fatalf("expected swagger path /docs, got %q", cfg.Swagger.Path)
|
|
||||||
}
|
|
||||||
if cfg.Transport.SwaggerPath != "/docs" {
|
|
||||||
t.Fatalf("expected transport swagger path /docs, got %q", cfg.Transport.SwaggerPath)
|
|
||||||
}
|
|
||||||
if cfg.Transport.GraphQLPlaygroundPath != "/graphql/playground" {
|
|
||||||
t.Fatalf("expected transport graphql playground path /graphql/playground, got %q", cfg.Transport.GraphQLPlaygroundPath)
|
|
||||||
}
|
|
||||||
if !cfg.Cache.Enabled || cfg.Cache.TTL != 5*time.Minute {
|
|
||||||
t.Fatalf("expected cache snapshot to be populated, got %+v", cfg.Cache)
|
|
||||||
}
|
|
||||||
if !cfg.GraphQL.Enabled {
|
|
||||||
t.Fatal("expected GraphQL snapshot to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.GraphQL.Path != "/graphql" {
|
|
||||||
t.Fatalf("expected GraphQL path /graphql, got %q", cfg.GraphQL.Path)
|
|
||||||
}
|
|
||||||
if !cfg.GraphQL.Playground {
|
|
||||||
t.Fatal("expected GraphQL playground snapshot to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.GraphQL.PlaygroundPath != "/graphql/playground" {
|
|
||||||
t.Fatalf("expected GraphQL playground path /graphql/playground, got %q", cfg.GraphQL.PlaygroundPath)
|
|
||||||
}
|
|
||||||
if cfg.I18n.DefaultLocale != "en-GB" {
|
|
||||||
t.Fatalf("expected default locale en-GB, got %q", cfg.I18n.DefaultLocale)
|
|
||||||
}
|
|
||||||
if !slices.Equal(cfg.I18n.Supported, []string{"en-GB", "fr"}) {
|
|
||||||
t.Fatalf("expected supported locales [en-GB fr], got %v", cfg.I18n.Supported)
|
|
||||||
}
|
|
||||||
if cfg.Authentik.Issuer != "https://auth.example.com" {
|
|
||||||
t.Fatalf("expected Authentik issuer https://auth.example.com, got %q", cfg.Authentik.Issuer)
|
|
||||||
}
|
|
||||||
if cfg.Authentik.ClientID != "runtime-client" {
|
|
||||||
t.Fatalf("expected Authentik client ID runtime-client, got %q", cfg.Authentik.ClientID)
|
|
||||||
}
|
|
||||||
if !cfg.Authentik.TrustedProxy {
|
|
||||||
t.Fatal("expected Authentik trusted proxy to be enabled")
|
|
||||||
}
|
|
||||||
if !slices.Equal(cfg.Authentik.PublicPaths, []string{"/public", "/docs"}) {
|
|
||||||
t.Fatalf("expected Authentik public paths [/public /docs], got %v", cfg.Authentik.PublicPaths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_RuntimeConfig_Good_EmptyOnNilEngine(t *testing.T) {
|
|
||||||
var e *api.Engine
|
|
||||||
|
|
||||||
cfg := e.RuntimeConfig()
|
|
||||||
if cfg.Swagger.Enabled || cfg.Transport.SwaggerEnabled || cfg.GraphQL.Enabled || cfg.Cache.Enabled || cfg.I18n.DefaultLocale != "" || cfg.Authentik.Issuer != "" {
|
|
||||||
t.Fatalf("expected zero-value runtime config, got %+v", cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_AuthentikConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
|
||||||
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
Issuer: "https://auth.example.com",
|
|
||||||
ClientID: "client",
|
|
||||||
TrustedProxy: true,
|
|
||||||
PublicPaths: []string{"/public", "/docs"},
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg := e.AuthentikConfig()
|
|
||||||
if cfg.Issuer != "https://auth.example.com" {
|
|
||||||
t.Fatalf("expected issuer https://auth.example.com, got %q", cfg.Issuer)
|
|
||||||
}
|
|
||||||
if cfg.ClientID != "client" {
|
|
||||||
t.Fatalf("expected client ID client, got %q", cfg.ClientID)
|
|
||||||
}
|
|
||||||
if !cfg.TrustedProxy {
|
|
||||||
t.Fatal("expected trusted proxy to be enabled")
|
|
||||||
}
|
|
||||||
if !slices.Equal(cfg.PublicPaths, []string{"/public", "/docs"}) {
|
|
||||||
t.Fatalf("expected public paths [/public /docs], got %v", cfg.PublicPaths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_AuthentikConfig_Good_ClonesPublicPaths(t *testing.T) {
|
|
||||||
publicPaths := []string{"/public", "/docs"}
|
|
||||||
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
Issuer: "https://auth.example.com",
|
|
||||||
PublicPaths: publicPaths,
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg := e.AuthentikConfig()
|
|
||||||
publicPaths[0] = "/mutated"
|
|
||||||
|
|
||||||
if cfg.PublicPaths[0] != "/public" {
|
|
||||||
t.Fatalf("expected snapshot to preserve original public paths, got %v", cfg.PublicPaths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_AuthentikConfig_Good_NormalisesPublicPaths(t *testing.T) {
|
|
||||||
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
PublicPaths: []string{" /public/ ", "docs", "/public"},
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg := e.AuthentikConfig()
|
|
||||||
expected := []string{"/public", "/docs"}
|
|
||||||
if !slices.Equal(cfg.PublicPaths, expected) {
|
|
||||||
t.Fatalf("expected normalised public paths %v, got %v", expected, cfg.PublicPaths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_AuthentikConfig_Good_BlankPublicPathsRemainNil(t *testing.T) {
|
|
||||||
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
PublicPaths: []string{" ", "\t", ""},
|
|
||||||
}))
|
|
||||||
|
|
||||||
cfg := e.AuthentikConfig()
|
|
||||||
if cfg.PublicPaths != nil {
|
|
||||||
t.Fatalf("expected blank public paths to collapse to nil, got %v", cfg.PublicPaths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Register_Good_IgnoresNilGroups(t *testing.T) {
|
|
||||||
e, _ := api.New()
|
|
||||||
|
|
||||||
var nilGroup *healthGroup
|
|
||||||
e.Register(nilGroup)
|
|
||||||
|
|
||||||
g1 := &healthGroup{}
|
|
||||||
e.Register(g1)
|
|
||||||
|
|
||||||
groups := e.Groups()
|
|
||||||
if len(groups) != 1 {
|
|
||||||
t.Fatalf("expected 1 registered group, got %d", len(groups))
|
|
||||||
}
|
|
||||||
if groups[0].Name() != "health-extra" {
|
|
||||||
t.Fatalf("expected the original group to be preserved, got %q", groups[0].Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToolBridge_Iterators(t *testing.T) {
|
|
||||||
b := api.NewToolBridge("/tools")
|
|
||||||
desc := api.ToolDescriptor{Name: "test", Group: "g1"}
|
|
||||||
b.Add(desc, nil)
|
|
||||||
|
|
||||||
// Test ToolsIter
|
|
||||||
var tools []api.ToolDescriptor
|
var tools []api.ToolDescriptor
|
||||||
for t := range b.ToolsIter() {
|
for tool := range bridge.ToolsIter() {
|
||||||
tools = append(tools, t)
|
tools = append(tools, tool)
|
||||||
}
|
}
|
||||||
if len(tools) != 1 || tools[0].Name != "test" {
|
if len(tools) != 1 || tools[0].Name != "test" {
|
||||||
t.Errorf("ToolsIter failed, got %v", tools)
|
t.Errorf("ToolsIter failed, got %v", tools)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test DescribeIter
|
|
||||||
var descs []api.RouteDescription
|
var descs []api.RouteDescription
|
||||||
for d := range b.DescribeIter() {
|
for desc := range bridge.DescribeIter() {
|
||||||
descs = append(descs, d)
|
descs = append(descs, desc)
|
||||||
}
|
}
|
||||||
if len(descs) != 1 || descs[0].Path != "/test" {
|
if len(descs) != 1 || descs[0].Path != "/test" {
|
||||||
t.Errorf("DescribeIter failed, got %v", descs)
|
t.Errorf("DescribeIter failed, got %v", descs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolBridge_Iterators_Good_SnapshotCurrentTools(t *testing.T) {
|
func TestModernization_ToolBridgeIterators_Bad(t *testing.T) {
|
||||||
b := api.NewToolBridge("/tools")
|
bridge := api.NewToolBridge("/tools")
|
||||||
b.Add(api.ToolDescriptor{Name: "first", Group: "g1"}, nil)
|
// Empty bridge — iterators must yield nothing.
|
||||||
|
for range bridge.ToolsIter() {
|
||||||
toolsIter := b.ToolsIter()
|
t.Fatal("expected no iterations on empty bridge (ToolsIter)")
|
||||||
descsIter := b.DescribeIter()
|
|
||||||
|
|
||||||
b.Add(api.ToolDescriptor{Name: "second", Group: "g2"}, nil)
|
|
||||||
|
|
||||||
var tools []api.ToolDescriptor
|
|
||||||
for tool := range toolsIter {
|
|
||||||
tools = append(tools, tool)
|
|
||||||
}
|
}
|
||||||
|
for range bridge.DescribeIter() {
|
||||||
var descs []api.RouteDescription
|
t.Fatal("expected no iterations on empty bridge (DescribeIter)")
|
||||||
for desc := range descsIter {
|
|
||||||
descs = append(descs, desc)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tools) != 1 || tools[0].Name != "first" {
|
|
||||||
t.Fatalf("expected ToolsIter snapshot to contain the original tool, got %v", tools)
|
|
||||||
}
|
|
||||||
if len(descs) != 1 || descs[0].Path != "/first" {
|
|
||||||
t.Fatalf("expected DescribeIter snapshot to contain the original tool, got %v", descs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodegen_SupportedLanguagesIter(t *testing.T) {
|
func TestModernization_ToolBridgeIterators_Ugly(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("ToolBridge iterator with nil handler panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bridge := api.NewToolBridge("/tools")
|
||||||
|
bridge.Add(api.ToolDescriptor{Name: "noop"}, nil)
|
||||||
|
|
||||||
|
var toolCount int
|
||||||
|
for range bridge.ToolsIter() {
|
||||||
|
toolCount++
|
||||||
|
}
|
||||||
|
if toolCount != 1 {
|
||||||
|
t.Fatalf("expected 1 tool, got %d", toolCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SupportedLanguagesIter ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestModernization_SupportedLanguagesIter_Good(t *testing.T) {
|
||||||
var langs []string
|
var langs []string
|
||||||
for l := range api.SupportedLanguagesIter() {
|
for language := range api.SupportedLanguagesIter() {
|
||||||
langs = append(langs, l)
|
langs = append(langs, language)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(langs, "go") {
|
if !slices.Contains(langs, "go") {
|
||||||
t.Errorf("SupportedLanguagesIter missing 'go'")
|
t.Errorf("SupportedLanguagesIter missing 'go'")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be sorted
|
|
||||||
if !slices.IsSorted(langs) {
|
if !slices.IsSorted(langs) {
|
||||||
t.Errorf("SupportedLanguagesIter should be sorted, got %v", langs)
|
t.Errorf("SupportedLanguagesIter should be sorted, got %v", langs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModernization_SupportedLanguagesIter_Bad(t *testing.T) {
|
||||||
|
// Iterator and slice function must agree on count.
|
||||||
|
iterCount := 0
|
||||||
|
for range api.SupportedLanguagesIter() {
|
||||||
|
iterCount++
|
||||||
|
}
|
||||||
|
sliceCount := len(api.SupportedLanguages())
|
||||||
|
if iterCount != sliceCount {
|
||||||
|
t.Fatalf("SupportedLanguagesIter count %d != SupportedLanguages count %d", iterCount, sliceCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModernization_SupportedLanguagesIter_Ugly(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("SupportedLanguagesIter panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Calling multiple times concurrently should not panic.
|
||||||
|
done := make(chan struct{}, 5)
|
||||||
|
for goroutineIndex := 0; goroutineIndex < 5; goroutineIndex++ {
|
||||||
|
go func() {
|
||||||
|
for range api.SupportedLanguagesIter() {
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for goroutineIndex := 0; goroutineIndex < 5; goroutineIndex++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
2129
openapi.go
2129
openapi.go
File diff suppressed because it is too large
Load diff
2757
openapi_test.go
2757
openapi_test.go
File diff suppressed because it is too large
Load diff
397
options.go
397
options.go
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
|
@ -27,17 +26,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Option configures an Engine during construction.
|
// Option configures an Engine during construction.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithAddr(":8080"))
|
|
||||||
type Option func(*Engine)
|
type Option func(*Engine)
|
||||||
|
|
||||||
// WithAddr sets the listen address for the server.
|
// WithAddr sets the listen address for the server.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithAddr(":8443"))
|
|
||||||
func WithAddr(addr string) Option {
|
func WithAddr(addr string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.addr = addr
|
e.addr = addr
|
||||||
|
|
@ -45,81 +36,46 @@ func WithAddr(addr string) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithBearerAuth adds bearer token authentication middleware.
|
// WithBearerAuth adds bearer token authentication middleware.
|
||||||
// Requests to /health and the Swagger UI path are exempt.
|
// Requests to /health and paths starting with /swagger are exempt.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithBearerAuth("secret"))
|
|
||||||
func WithBearerAuth(token string) Option {
|
func WithBearerAuth(token string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
|
skip := []string{"/health", "/swagger"}
|
||||||
skip := []string{"/health"}
|
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
|
||||||
if swaggerPath := resolveSwaggerPath(e.swaggerPath); swaggerPath != "" {
|
|
||||||
skip = append(skip, swaggerPath)
|
|
||||||
}
|
|
||||||
return skip
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
|
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
|
||||||
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
|
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithRequestID())
|
|
||||||
func WithRequestID() Option {
|
func WithRequestID() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, requestIDMiddleware())
|
e.middlewares = append(e.middlewares, requestIDMiddleware())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithResponseMeta attaches request metadata to JSON envelope responses.
|
|
||||||
// It preserves any existing pagination metadata and merges in request_id
|
|
||||||
// and duration when available from the request context. Combine it with
|
|
||||||
// WithRequestID() to populate both fields automatically.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithRequestID(), api.WithResponseMeta())
|
|
||||||
func WithResponseMeta() Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.middlewares = append(e.middlewares, responseMetaMiddleware())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
|
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
|
||||||
// Pass "*" to allow all origins, or supply specific origin URLs.
|
// Pass "*" to allow all origins, or supply specific origin URLs.
|
||||||
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
||||||
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
|
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithCORS("*"))
|
|
||||||
func WithCORS(allowOrigins ...string) Option {
|
func WithCORS(allowOrigins ...string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
cfg := cors.Config{
|
corsConfig := cors.Config{
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
||||||
MaxAge: 12 * time.Hour,
|
MaxAge: 12 * time.Hour,
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(allowOrigins, "*") {
|
if slices.Contains(allowOrigins, "*") {
|
||||||
cfg.AllowAllOrigins = true
|
corsConfig.AllowAllOrigins = true
|
||||||
}
|
}
|
||||||
if !cfg.AllowAllOrigins {
|
if !corsConfig.AllowAllOrigins {
|
||||||
cfg.AllowOrigins = allowOrigins
|
corsConfig.AllowOrigins = allowOrigins
|
||||||
}
|
}
|
||||||
|
|
||||||
e.middlewares = append(e.middlewares, cors.New(cfg))
|
e.middlewares = append(e.middlewares, cors.New(corsConfig))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithMiddleware appends arbitrary Gin middleware to the engine.
|
// WithMiddleware appends arbitrary Gin middleware to the engine.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithMiddleware(loggingMiddleware))
|
|
||||||
func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, mw...)
|
e.middlewares = append(e.middlewares, mw...)
|
||||||
|
|
@ -129,10 +85,6 @@ func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
||||||
// WithStatic serves static files from the given root directory at urlPrefix.
|
// WithStatic serves static files from the given root directory at urlPrefix.
|
||||||
// Directory listing is disabled; only individual files are served.
|
// Directory listing is disabled; only individual files are served.
|
||||||
// Internally this uses gin-contrib/static as Gin middleware.
|
// Internally this uses gin-contrib/static as Gin middleware.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithStatic("/assets", "./public"))
|
|
||||||
func WithStatic(urlPrefix, root string) Option {
|
func WithStatic(urlPrefix, root string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
|
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
|
||||||
|
|
@ -140,215 +92,33 @@ func WithStatic(urlPrefix, root string) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithWSHandler registers a WebSocket handler at GET /ws.
|
// WithWSHandler registers a WebSocket handler at GET /ws.
|
||||||
// Use WithWSPath to customise the route before mounting the handler.
|
|
||||||
// Typically this wraps a go-ws Hub.Handler().
|
// Typically this wraps a go-ws Hub.Handler().
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithWSHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
|
|
||||||
func WithWSHandler(h http.Handler) Option {
|
func WithWSHandler(h http.Handler) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.wsHandler = h
|
e.wsHandler = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithWSPath sets a custom URL path for the WebSocket endpoint.
|
|
||||||
// The default path is "/ws".
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithWSPath("/socket"))
|
|
||||||
func WithWSPath(path string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.wsPath = normaliseWSPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithAuthentik adds Authentik forward-auth middleware that extracts user
|
// WithAuthentik adds Authentik forward-auth middleware that extracts user
|
||||||
// identity from X-authentik-* headers set by a trusted reverse proxy.
|
// identity from X-authentik-* headers set by a trusted reverse proxy.
|
||||||
// The middleware is permissive: unauthenticated requests are allowed through.
|
// The middleware is permissive: unauthenticated requests are allowed through.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
|
|
||||||
func WithAuthentik(cfg AuthentikConfig) Option {
|
func WithAuthentik(cfg AuthentikConfig) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
snapshot := cloneAuthentikConfig(cfg)
|
e.middlewares = append(e.middlewares, authentikMiddleware(cfg))
|
||||||
e.authentikConfig = snapshot
|
|
||||||
e.middlewares = append(e.middlewares, authentikMiddleware(snapshot, func() []string {
|
|
||||||
return []string{resolveSwaggerPath(e.swaggerPath)}
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSunset adds deprecation headers to every response.
|
// WithSwagger enables the Swagger UI at /swagger/.
|
||||||
// The middleware appends Deprecation, optional Sunset, optional Link, and
|
|
||||||
// X-API-Warn headers without clobbering any existing header values. Use it to
|
|
||||||
// deprecate an entire route group or API version.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSunset("2026-12-31", "https://api.example.com/v2"))
|
|
||||||
func WithSunset(sunsetDate, replacement string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.middlewares = append(e.middlewares, ApiSunset(sunsetDate, replacement))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwagger enables the Swagger UI at /swagger/ by default.
|
|
||||||
// The title, description, and version populate the OpenAPI info block.
|
// The title, description, and version populate the OpenAPI info block.
|
||||||
// Use WithSwaggerSummary() to set the optional info.summary field.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSwagger("Service", "Public API", "1.0.0"))
|
|
||||||
func WithSwagger(title, description, version string) Option {
|
func WithSwagger(title, description, version string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.swaggerTitle = strings.TrimSpace(title)
|
e.swaggerTitle = title
|
||||||
e.swaggerDesc = strings.TrimSpace(description)
|
e.swaggerDesc = description
|
||||||
e.swaggerVersion = strings.TrimSpace(version)
|
e.swaggerVersion = version
|
||||||
e.swaggerEnabled = true
|
e.swaggerEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSwaggerSummary adds the OpenAPI info.summary field to generated specs.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerSummary("Service overview")
|
|
||||||
func WithSwaggerSummary(summary string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if summary = strings.TrimSpace(summary); summary != "" {
|
|
||||||
e.swaggerSummary = summary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerPath sets a custom URL path for the Swagger UI.
|
|
||||||
// The default path is "/swagger".
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSwaggerPath("/docs"))
|
|
||||||
func WithSwaggerPath(path string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.swaggerPath = normaliseSwaggerPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerTermsOfService adds the terms of service URL to the generated Swagger spec.
|
|
||||||
// Empty strings are ignored.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerTermsOfService("https://example.com/terms")
|
|
||||||
func WithSwaggerTermsOfService(url string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if url = strings.TrimSpace(url); url != "" {
|
|
||||||
e.swaggerTermsOfService = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerContact adds contact metadata to the generated Swagger spec.
|
|
||||||
// Empty fields are ignored. Multiple calls replace the previous contact data.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com")
|
|
||||||
func WithSwaggerContact(name, url, email string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
|
||||||
e.swaggerContactName = name
|
|
||||||
}
|
|
||||||
if url = strings.TrimSpace(url); url != "" {
|
|
||||||
e.swaggerContactURL = url
|
|
||||||
}
|
|
||||||
if email = strings.TrimSpace(email); email != "" {
|
|
||||||
e.swaggerContactEmail = email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerServers adds OpenAPI server metadata to the generated Swagger spec.
|
|
||||||
// Empty strings are ignored. Multiple calls append and normalise the combined
|
|
||||||
// server list so callers can compose metadata across options.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerServers("https://api.example.com", "https://docs.example.com")
|
|
||||||
func WithSwaggerServers(servers ...string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.swaggerServers = normaliseServers(append(e.swaggerServers, servers...))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerLicense adds licence metadata to the generated Swagger spec.
|
|
||||||
// Pass both a name and URL to populate the OpenAPI info block consistently.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/")
|
|
||||||
func WithSwaggerLicense(name, url string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
|
||||||
e.swaggerLicenseName = name
|
|
||||||
}
|
|
||||||
if url = strings.TrimSpace(url); url != "" {
|
|
||||||
e.swaggerLicenseURL = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerSecuritySchemes merges custom OpenAPI security schemes into the
|
|
||||||
// generated Swagger spec. Existing schemes are preserved unless the new map
|
|
||||||
// defines the same key, in which case the later definition wins.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerSecuritySchemes(map[string]any{
|
|
||||||
// "apiKeyAuth": map[string]any{
|
|
||||||
// "type": "apiKey",
|
|
||||||
// "in": "header",
|
|
||||||
// "name": "X-API-Key",
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
func WithSwaggerSecuritySchemes(schemes map[string]any) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if len(schemes) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if e.swaggerSecuritySchemes == nil {
|
|
||||||
e.swaggerSecuritySchemes = make(map[string]any, len(schemes))
|
|
||||||
}
|
|
||||||
for name, scheme := range schemes {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" || scheme == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e.swaggerSecuritySchemes[name] = cloneOpenAPIValue(scheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSwaggerExternalDocs adds top-level external documentation metadata to
|
|
||||||
// the generated Swagger spec.
|
|
||||||
// Empty URLs are ignored; the description is optional.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs")
|
|
||||||
func WithSwaggerExternalDocs(description, url string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
if description = strings.TrimSpace(description); description != "" {
|
|
||||||
e.swaggerExternalDocsDescription = description
|
|
||||||
}
|
|
||||||
if url = strings.TrimSpace(url); url != "" {
|
|
||||||
e.swaggerExternalDocsURL = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
|
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
|
||||||
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
|
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
|
||||||
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
|
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
|
||||||
|
|
@ -356,10 +126,6 @@ func WithSwaggerExternalDocs(description, url string) Option {
|
||||||
//
|
//
|
||||||
// WARNING: pprof exposes sensitive runtime data and should only be
|
// WARNING: pprof exposes sensitive runtime data and should only be
|
||||||
// enabled in development or behind authentication in production.
|
// enabled in development or behind authentication in production.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithPprof())
|
|
||||||
func WithPprof() Option {
|
func WithPprof() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.pprofEnabled = true
|
e.pprofEnabled = true
|
||||||
|
|
@ -374,10 +140,6 @@ func WithPprof() Option {
|
||||||
// WARNING: expvar exposes runtime internals (memory allocation,
|
// WARNING: expvar exposes runtime internals (memory allocation,
|
||||||
// goroutine counts, command-line arguments) and should only be
|
// goroutine counts, command-line arguments) and should only be
|
||||||
// enabled in development or behind authentication in production.
|
// enabled in development or behind authentication in production.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithExpvar())
|
|
||||||
func WithExpvar() Option {
|
func WithExpvar() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.expvarEnabled = true
|
e.expvarEnabled = true
|
||||||
|
|
@ -389,10 +151,6 @@ func WithExpvar() Option {
|
||||||
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
|
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
|
||||||
// SSL redirect is not enabled so the middleware works behind a reverse proxy
|
// SSL redirect is not enabled so the middleware works behind a reverse proxy
|
||||||
// that terminates TLS.
|
// that terminates TLS.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSecure())
|
|
||||||
func WithSecure() Option {
|
func WithSecure() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, secure.New(secure.Config{
|
e.middlewares = append(e.middlewares, secure.New(secure.Config{
|
||||||
|
|
@ -409,10 +167,6 @@ func WithSecure() Option {
|
||||||
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
|
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
|
||||||
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
|
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
|
||||||
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
|
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithGzip())
|
|
||||||
func WithGzip(level ...int) Option {
|
func WithGzip(level ...int) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
l := gzip.DefaultCompression
|
l := gzip.DefaultCompression
|
||||||
|
|
@ -426,10 +180,6 @@ func WithGzip(level ...int) Option {
|
||||||
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
|
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
|
||||||
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
|
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
|
||||||
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
|
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithBrotli())
|
|
||||||
func WithBrotli(level ...int) Option {
|
func WithBrotli(level ...int) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
l := BrotliDefaultCompression
|
l := BrotliDefaultCompression
|
||||||
|
|
@ -443,10 +193,6 @@ func WithBrotli(level ...int) Option {
|
||||||
// WithSlog adds structured request logging middleware via gin-contrib/slog.
|
// WithSlog adds structured request logging middleware via gin-contrib/slog.
|
||||||
// Each request is logged with method, path, status code, latency, and client IP.
|
// Each request is logged with method, path, status code, latency, and client IP.
|
||||||
// If logger is nil, slog.Default() is used.
|
// If logger is nil, slog.Default() is used.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSlog(nil))
|
|
||||||
func WithSlog(logger *slog.Logger) Option {
|
func WithSlog(logger *slog.Logger) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
|
|
@ -468,15 +214,8 @@ func WithSlog(logger *slog.Logger) Option {
|
||||||
//
|
//
|
||||||
// A zero or negative duration effectively disables the timeout (the handler
|
// A zero or negative duration effectively disables the timeout (the handler
|
||||||
// runs without a deadline) — this is safe and will not panic.
|
// runs without a deadline) — this is safe and will not panic.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithTimeout(5 * time.Second))
|
|
||||||
func WithTimeout(d time.Duration) Option {
|
func WithTimeout(d time.Duration) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
if d <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.middlewares = append(e.middlewares, timeout.New(
|
e.middlewares = append(e.middlewares, timeout.New(
|
||||||
timeout.WithTimeout(d),
|
timeout.WithTimeout(d),
|
||||||
timeout.WithResponse(timeoutResponse),
|
timeout.WithResponse(timeoutResponse),
|
||||||
|
|
@ -493,77 +232,17 @@ func timeoutResponse(c *gin.Context) {
|
||||||
// Successful (2xx) GET responses are cached for the given TTL and served
|
// Successful (2xx) GET responses are cached for the given TTL and served
|
||||||
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
|
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
|
||||||
// and error responses pass through uncached.
|
// and error responses pass through uncached.
|
||||||
//
|
func WithCache(ttl time.Duration) Option {
|
||||||
// Optional integer limits enable LRU eviction:
|
|
||||||
// - maxEntries limits the number of cached responses
|
|
||||||
// - maxBytes limits the approximate total cached payload size
|
|
||||||
//
|
|
||||||
// Pass a non-positive value to either limit to leave that dimension
|
|
||||||
// unbounded for backward compatibility. A non-positive TTL disables the
|
|
||||||
// middleware entirely.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithCache(5*time.Minute, 100, 10<<20))
|
|
||||||
func WithCache(ttl time.Duration, maxEntries ...int) Option {
|
|
||||||
entryLimit := 0
|
|
||||||
byteLimit := 0
|
|
||||||
if len(maxEntries) > 0 {
|
|
||||||
entryLimit = maxEntries[0]
|
|
||||||
}
|
|
||||||
if len(maxEntries) > 1 {
|
|
||||||
byteLimit = maxEntries[1]
|
|
||||||
}
|
|
||||||
return WithCacheLimits(ttl, entryLimit, byteLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithCacheLimits adds in-memory response caching middleware for GET requests
|
|
||||||
// with explicit entry and payload-size bounds.
|
|
||||||
//
|
|
||||||
// This is the clearer form of WithCache when call sites want to make the
|
|
||||||
// eviction policy self-documenting.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
|
|
||||||
func WithCacheLimits(ttl time.Duration, maxEntries, maxBytes int) Option {
|
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
if ttl <= 0 {
|
store := newCacheStore()
|
||||||
return
|
|
||||||
}
|
|
||||||
e.cacheTTL = ttl
|
|
||||||
e.cacheMaxEntries = maxEntries
|
|
||||||
e.cacheMaxBytes = maxBytes
|
|
||||||
store := newCacheStore(maxEntries, maxBytes)
|
|
||||||
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
|
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithRateLimit adds token-bucket rate limiting middleware.
|
|
||||||
// Requests are bucketed by API key or bearer token when present, and
|
|
||||||
// otherwise by client IP. Passing requests are annotated with
|
|
||||||
// X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
|
|
||||||
// Requests exceeding the configured limit are rejected with 429 Too Many
|
|
||||||
// Requests, Retry-After, and the standard Fail() error envelope.
|
|
||||||
// A zero or negative limit disables rate limiting.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithRateLimit(100))
|
|
||||||
func WithRateLimit(limit int) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.middlewares = append(e.middlewares, rateLimitMiddleware(limit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSessions adds server-side session management middleware via
|
// WithSessions adds server-side session management middleware via
|
||||||
// gin-contrib/sessions using a cookie-based store. The name parameter
|
// gin-contrib/sessions using a cookie-based store. The name parameter
|
||||||
// sets the session cookie name (e.g. "session") and secret is the key
|
// sets the session cookie name (e.g. "session") and secret is the key
|
||||||
// used for cookie signing and encryption.
|
// used for cookie signing and encryption.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSessions("session", []byte("secret")))
|
|
||||||
func WithSessions(name string, secret []byte) Option {
|
func WithSessions(name string, secret []byte) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
store := cookie.NewStore(secret)
|
store := cookie.NewStore(secret)
|
||||||
|
|
@ -576,10 +255,6 @@ func WithSessions(name string, secret []byte) Option {
|
||||||
// holding the desired model and policy rules. The middleware extracts the
|
// holding the desired model and policy rules. The middleware extracts the
|
||||||
// subject from HTTP Basic Authentication, evaluates it against the request
|
// subject from HTTP Basic Authentication, evaluates it against the request
|
||||||
// method and path, and returns 403 Forbidden when the policy denies access.
|
// method and path, and returns 403 Forbidden when the policy denies access.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithAuthz(enforcer))
|
|
||||||
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
||||||
|
|
@ -599,10 +274,6 @@ func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||||
//
|
//
|
||||||
// Requests with a missing, malformed, or invalid signature are rejected with
|
// Requests with a missing, malformed, or invalid signature are rejected with
|
||||||
// 401 Unauthorised or 400 Bad Request.
|
// 401 Unauthorised or 400 Bad Request.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithHTTPSign(secrets))
|
|
||||||
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
auth := httpsign.NewAuthenticator(secrets, opts...)
|
auth := httpsign.NewAuthenticator(secrets, opts...)
|
||||||
|
|
@ -610,34 +281,16 @@ func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSSE registers a Server-Sent Events broker at the configured path.
|
// WithSSE registers a Server-Sent Events broker at GET /events.
|
||||||
// By default the endpoint is mounted at GET /events; use WithSSEPath to
|
// Clients connect to the endpoint and receive a streaming text/event-stream
|
||||||
// customise the route. Clients receive a streaming text/event-stream
|
// response. The broker manages client connections and broadcasts events
|
||||||
// response and the broker manages client connections and broadcasts events
|
|
||||||
// published via its Publish method.
|
// published via its Publish method.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// broker := api.NewSSEBroker()
|
|
||||||
// engine, _ := api.New(api.WithSSE(broker))
|
|
||||||
func WithSSE(broker *SSEBroker) Option {
|
func WithSSE(broker *SSEBroker) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.sseBroker = broker
|
e.sseBroker = broker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSSEPath sets a custom URL path for the SSE endpoint.
|
|
||||||
// The default path is "/events".
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.New(api.WithSSEPath("/stream"))
|
|
||||||
func WithSSEPath(path string) Option {
|
|
||||||
return func(e *Engine) {
|
|
||||||
e.ssePath = normaliseSSEPath(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLocation adds reverse proxy header detection middleware via
|
// WithLocation adds reverse proxy header detection middleware via
|
||||||
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
|
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
|
||||||
// headers to determine the original scheme and host when the server runs
|
// headers to determine the original scheme and host when the server runs
|
||||||
|
|
@ -645,10 +298,6 @@ func WithSSEPath(path string) Option {
|
||||||
//
|
//
|
||||||
// After this middleware runs, handlers can call location.Get(c) to retrieve
|
// After this middleware runs, handlers can call location.Get(c) to retrieve
|
||||||
// a *url.URL with the detected scheme, host, and base path.
|
// a *url.URL with the detected scheme, host, and base path.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithLocation())
|
|
||||||
func WithLocation() Option {
|
func WithLocation() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, location.Default())
|
e.middlewares = append(e.middlewares, location.Default())
|
||||||
|
|
@ -662,19 +311,15 @@ func WithLocation() Option {
|
||||||
// api.New(
|
// api.New(
|
||||||
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
||||||
// )
|
// )
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// engine, _ := api.New(api.WithGraphQL(schema))
|
|
||||||
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
|
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
cfg := &graphqlConfig{
|
graphqlCfg := &graphqlConfig{
|
||||||
schema: schema,
|
schema: schema,
|
||||||
path: defaultGraphQLPath,
|
path: defaultGraphQLPath,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(cfg)
|
opt(graphqlCfg)
|
||||||
}
|
}
|
||||||
e.graphql = cfg
|
e.graphql = graphqlCfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package provider defines the Service Provider Framework interfaces.
|
// Package provider defines the Service Provider Framework interfaces.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package provider
|
package provider
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
coreapi "dappco.re/go/core/api"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,20 +39,14 @@ type ProxyConfig struct {
|
||||||
type ProxyProvider struct {
|
type ProxyProvider struct {
|
||||||
config ProxyConfig
|
config ProxyConfig
|
||||||
proxy *httputil.ReverseProxy
|
proxy *httputil.ReverseProxy
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProxy creates a ProxyProvider from the given configuration.
|
// NewProxy creates a ProxyProvider from the given configuration.
|
||||||
// Invalid upstream URLs do not panic; the provider retains the
|
// The upstream URL must be valid or NewProxy will panic.
|
||||||
// configuration error and responds with a standard 500 envelope when
|
|
||||||
// mounted. This keeps provider construction safe for callers.
|
|
||||||
func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||||
target, err := url.Parse(cfg.Upstream)
|
target, err := url.Parse(cfg.Upstream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ProxyProvider{
|
panic("provider.NewProxy: invalid upstream URL: " + err.Error())
|
||||||
config: cfg,
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
|
@ -66,10 +59,11 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||||
proxy.Director = func(req *http.Request) {
|
proxy.Director = func(req *http.Request) {
|
||||||
defaultDirector(req)
|
defaultDirector(req)
|
||||||
// Strip the base path prefix from the request path.
|
// Strip the base path prefix from the request path.
|
||||||
req.URL.Path = stripBasePath(req.URL.Path, basePath)
|
req.URL.Path = strings.TrimPrefix(req.URL.Path, basePath)
|
||||||
if req.URL.RawPath != "" {
|
if req.URL.Path == "" {
|
||||||
req.URL.RawPath = stripBasePath(req.URL.RawPath, basePath)
|
req.URL.Path = "/"
|
||||||
}
|
}
|
||||||
|
req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, basePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ProxyProvider{
|
return &ProxyProvider{
|
||||||
|
|
@ -78,43 +72,6 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err reports any configuration error detected while constructing the proxy.
|
|
||||||
// A nil error means the proxy is ready to mount and serve requests.
|
|
||||||
func (p *ProxyProvider) Err() error {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripBasePath removes an exact base path prefix from a request path.
|
|
||||||
// It only strips when the path matches the base path itself or lives under
|
|
||||||
// the base path boundary, so "/api" will not accidentally trim "/api-v2".
|
|
||||||
func stripBasePath(path, basePath string) string {
|
|
||||||
basePath = strings.TrimSuffix(strings.TrimSpace(basePath), "/")
|
|
||||||
if basePath == "" || basePath == "/" {
|
|
||||||
if path == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == basePath {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := basePath + "/"
|
|
||||||
if strings.HasPrefix(path, prefix) {
|
|
||||||
trimmed := strings.TrimPrefix(path, basePath)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the provider identity.
|
// Name returns the provider identity.
|
||||||
func (p *ProxyProvider) Name() string {
|
func (p *ProxyProvider) Name() string {
|
||||||
return p.config.Name
|
return p.config.Name
|
||||||
|
|
@ -128,19 +85,6 @@ func (p *ProxyProvider) BasePath() string {
|
||||||
// RegisterRoutes mounts a catch-all reverse proxy handler on the router group.
|
// RegisterRoutes mounts a catch-all reverse proxy handler on the router group.
|
||||||
func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) {
|
func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
rg.Any("/*path", func(c *gin.Context) {
|
rg.Any("/*path", func(c *gin.Context) {
|
||||||
if p == nil || p.err != nil || p.proxy == nil {
|
|
||||||
details := map[string]any{}
|
|
||||||
if p != nil && p.err != nil {
|
|
||||||
details["error"] = p.err.Error()
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, coreapi.FailWithDetails(
|
|
||||||
"invalid_provider_configuration",
|
|
||||||
"Provider is misconfigured",
|
|
||||||
details,
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the underlying http.ResponseWriter directly. Gin's
|
// Use the underlying http.ResponseWriter directly. Gin's
|
||||||
// responseWriter wrapper does not implement http.CloseNotifier,
|
// responseWriter wrapper does not implement http.CloseNotifier,
|
||||||
// which httputil.ReverseProxy requires for cancellation signalling.
|
// which httputil.ReverseProxy requires for cancellation signalling.
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package provider
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestStripBasePath_Good_ExactBoundary(t *testing.T) {
|
|
||||||
got := stripBasePath("/api/v1/cool-widget/items", "/api/v1/cool-widget")
|
|
||||||
if got != "/items" {
|
|
||||||
t.Fatalf("expected stripped path %q, got %q", "/items", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripBasePath_Good_RootPath(t *testing.T) {
|
|
||||||
got := stripBasePath("/api/v1/cool-widget", "/api/v1/cool-widget")
|
|
||||||
if got != "/" {
|
|
||||||
t.Fatalf("expected stripped root path %q, got %q", "/", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripBasePath_Good_DoesNotTrimPartialPrefix(t *testing.T) {
|
|
||||||
got := stripBasePath("/api/v1/cool-widget-2/items", "/api/v1/cool-widget")
|
|
||||||
if got != "/api/v1/cool-widget-2/items" {
|
|
||||||
t.Fatalf("expected partial prefix to remain unchanged, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -183,32 +183,11 @@ func TestProxyProvider_Renderable_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
|
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
|
||||||
p := provider.NewProxy(provider.ProxyConfig{
|
assert.Panics(t, func() {
|
||||||
Name: "bad",
|
provider.NewProxy(provider.ProxyConfig{
|
||||||
BasePath: "/api/v1/bad",
|
Name: "bad",
|
||||||
Upstream: "://not-a-url",
|
BasePath: "/api/v1/bad",
|
||||||
|
Upstream: "://not-a-url",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NotNil(t, p)
|
|
||||||
assert.Error(t, p.Err())
|
|
||||||
|
|
||||||
engine, err := api.New()
|
|
||||||
require.NoError(t, err)
|
|
||||||
engine.Register(p)
|
|
||||||
|
|
||||||
handler := engine.Handler()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/bad/items", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
|
|
||||||
var body map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
||||||
|
|
||||||
assert.Equal(t, false, body["success"])
|
|
||||||
errObj, ok := body["error"].(map[string]any)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, "invalid_provider_configuration", errObj["code"])
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package provider
|
package provider
|
||||||
|
|
||||||
|
|
@ -88,24 +88,6 @@ func (r *Registry) Streamable() []Streamable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamableIter returns an iterator over all registered providers that
|
|
||||||
// implement the Streamable interface.
|
|
||||||
func (r *Registry) StreamableIter() iter.Seq[Streamable] {
|
|
||||||
r.mu.RLock()
|
|
||||||
providers := slices.Clone(r.providers)
|
|
||||||
r.mu.RUnlock()
|
|
||||||
|
|
||||||
return func(yield func(Streamable) bool) {
|
|
||||||
for _, p := range providers {
|
|
||||||
if s, ok := p.(Streamable); ok {
|
|
||||||
if !yield(s) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Describable returns all providers that implement the Describable interface.
|
// Describable returns all providers that implement the Describable interface.
|
||||||
func (r *Registry) Describable() []Describable {
|
func (r *Registry) Describable() []Describable {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
@ -119,24 +101,6 @@ func (r *Registry) Describable() []Describable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// DescribableIter returns an iterator over all registered providers that
|
|
||||||
// implement the Describable interface.
|
|
||||||
func (r *Registry) DescribableIter() iter.Seq[Describable] {
|
|
||||||
r.mu.RLock()
|
|
||||||
providers := slices.Clone(r.providers)
|
|
||||||
r.mu.RUnlock()
|
|
||||||
|
|
||||||
return func(yield func(Describable) bool) {
|
|
||||||
for _, p := range providers {
|
|
||||||
if d, ok := p.(Describable); ok {
|
|
||||||
if !yield(d) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderable returns all providers that implement the Renderable interface.
|
// Renderable returns all providers that implement the Renderable interface.
|
||||||
func (r *Registry) Renderable() []Renderable {
|
func (r *Registry) Renderable() []Renderable {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
@ -150,32 +114,12 @@ func (r *Registry) Renderable() []Renderable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderableIter returns an iterator over all registered providers that
|
|
||||||
// implement the Renderable interface.
|
|
||||||
func (r *Registry) RenderableIter() iter.Seq[Renderable] {
|
|
||||||
r.mu.RLock()
|
|
||||||
providers := slices.Clone(r.providers)
|
|
||||||
r.mu.RUnlock()
|
|
||||||
|
|
||||||
return func(yield func(Renderable) bool) {
|
|
||||||
for _, p := range providers {
|
|
||||||
if rv, ok := p.(Renderable); ok {
|
|
||||||
if !yield(rv) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProviderInfo is a serialisable summary of a registered provider.
|
// ProviderInfo is a serialisable summary of a registered provider.
|
||||||
type ProviderInfo struct {
|
type ProviderInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BasePath string `json:"basePath"`
|
BasePath string `json:"basePath"`
|
||||||
Channels []string `json:"channels,omitempty"`
|
Channels []string `json:"channels,omitempty"`
|
||||||
Element *ElementSpec `json:"element,omitempty"`
|
Element *ElementSpec `json:"element,omitempty"`
|
||||||
SpecFile string `json:"specFile,omitempty"`
|
|
||||||
Upstream string `json:"upstream,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info returns a summary of all registered providers.
|
// Info returns a summary of all registered providers.
|
||||||
|
|
@ -196,76 +140,7 @@ func (r *Registry) Info() []ProviderInfo {
|
||||||
elem := rv.Element()
|
elem := rv.Element()
|
||||||
info.Element = &elem
|
info.Element = &elem
|
||||||
}
|
}
|
||||||
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
|
||||||
info.SpecFile = sf.SpecFile()
|
|
||||||
}
|
|
||||||
if up, ok := p.(interface{ Upstream() string }); ok {
|
|
||||||
info.Upstream = up.Upstream()
|
|
||||||
}
|
|
||||||
infos = append(infos, info)
|
infos = append(infos, info)
|
||||||
}
|
}
|
||||||
return infos
|
return infos
|
||||||
}
|
}
|
||||||
|
|
||||||
// InfoIter returns an iterator over all registered provider summaries.
|
|
||||||
// The iterator snapshots the current registry contents so callers can range
|
|
||||||
// over it without holding the registry lock.
|
|
||||||
func (r *Registry) InfoIter() iter.Seq[ProviderInfo] {
|
|
||||||
r.mu.RLock()
|
|
||||||
providers := slices.Clone(r.providers)
|
|
||||||
r.mu.RUnlock()
|
|
||||||
|
|
||||||
return func(yield func(ProviderInfo) bool) {
|
|
||||||
for _, p := range providers {
|
|
||||||
info := ProviderInfo{
|
|
||||||
Name: p.Name(),
|
|
||||||
BasePath: p.BasePath(),
|
|
||||||
}
|
|
||||||
if s, ok := p.(Streamable); ok {
|
|
||||||
info.Channels = s.Channels()
|
|
||||||
}
|
|
||||||
if rv, ok := p.(Renderable); ok {
|
|
||||||
elem := rv.Element()
|
|
||||||
info.Element = &elem
|
|
||||||
}
|
|
||||||
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
|
||||||
info.SpecFile = sf.SpecFile()
|
|
||||||
}
|
|
||||||
if up, ok := p.(interface{ Upstream() string }); ok {
|
|
||||||
info.Upstream = up.Upstream()
|
|
||||||
}
|
|
||||||
if !yield(info) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpecFiles returns all non-empty provider OpenAPI spec file paths.
|
|
||||||
// The result is deduplicated and sorted for stable discovery output.
|
|
||||||
func (r *Registry) SpecFiles() []string {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
|
|
||||||
files := make(map[string]struct{}, len(r.providers))
|
|
||||||
for _, p := range r.providers {
|
|
||||||
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
|
||||||
if path := sf.SpecFile(); path != "" {
|
|
||||||
files[path] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(files))
|
|
||||||
for path := range files {
|
|
||||||
out = append(out, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpecFilesIter returns an iterator over all non-empty provider OpenAPI spec files.
|
|
||||||
func (r *Registry) SpecFilesIter() iter.Seq[string] {
|
|
||||||
return slices.Values(r.SpecFiles())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,6 @@ func (r *renderableProvider) Element() provider.ElementSpec {
|
||||||
return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
|
return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
|
||||||
}
|
}
|
||||||
|
|
||||||
type specFileProvider struct {
|
|
||||||
stubProvider
|
|
||||||
specFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *specFileProvider) SpecFile() string { return s.specFile }
|
|
||||||
|
|
||||||
type fullProvider struct {
|
type fullProvider struct {
|
||||||
streamableProvider
|
streamableProvider
|
||||||
}
|
}
|
||||||
|
|
@ -119,39 +112,9 @@ func TestRegistry_Streamable_Good(t *testing.T) {
|
||||||
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
|
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_StreamableIter_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&stubProvider{})
|
|
||||||
reg.Add(&streamableProvider{})
|
|
||||||
|
|
||||||
var streamables []provider.Streamable
|
|
||||||
for s := range reg.StreamableIter() {
|
|
||||||
streamables = append(streamables, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, streamables, 1)
|
|
||||||
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_StreamableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&streamableProvider{})
|
|
||||||
|
|
||||||
iter := reg.StreamableIter()
|
|
||||||
reg.Add(&streamableProvider{})
|
|
||||||
|
|
||||||
var streamables []provider.Streamable
|
|
||||||
for s := range iter {
|
|
||||||
streamables = append(streamables, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, streamables, 1)
|
|
||||||
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_Describable_Good(t *testing.T) {
|
func TestRegistry_Describable_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{}) // not describable
|
reg.Add(&stubProvider{}) // not describable
|
||||||
reg.Add(&describableProvider{}) // describable
|
reg.Add(&describableProvider{}) // describable
|
||||||
|
|
||||||
d := reg.Describable()
|
d := reg.Describable()
|
||||||
|
|
@ -159,36 +122,6 @@ func TestRegistry_Describable_Good(t *testing.T) {
|
||||||
assert.Len(t, d[0].Describe(), 1)
|
assert.Len(t, d[0].Describe(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_DescribableIter_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&stubProvider{})
|
|
||||||
reg.Add(&describableProvider{})
|
|
||||||
|
|
||||||
var describables []provider.Describable
|
|
||||||
for d := range reg.DescribableIter() {
|
|
||||||
describables = append(describables, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, describables, 1)
|
|
||||||
assert.Len(t, describables[0].Describe(), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_DescribableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&describableProvider{})
|
|
||||||
|
|
||||||
iter := reg.DescribableIter()
|
|
||||||
reg.Add(&describableProvider{})
|
|
||||||
|
|
||||||
var describables []provider.Describable
|
|
||||||
for d := range iter {
|
|
||||||
describables = append(describables, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, describables, 1)
|
|
||||||
assert.Len(t, describables[0].Describe(), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_Renderable_Good(t *testing.T) {
|
func TestRegistry_Renderable_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{}) // not renderable
|
reg.Add(&stubProvider{}) // not renderable
|
||||||
|
|
@ -199,36 +132,6 @@ func TestRegistry_Renderable_Good(t *testing.T) {
|
||||||
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
|
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_RenderableIter_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&stubProvider{})
|
|
||||||
reg.Add(&renderableProvider{})
|
|
||||||
|
|
||||||
var renderables []provider.Renderable
|
|
||||||
for r := range reg.RenderableIter() {
|
|
||||||
renderables = append(renderables, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, renderables, 1)
|
|
||||||
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_RenderableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&renderableProvider{})
|
|
||||||
|
|
||||||
iter := reg.RenderableIter()
|
|
||||||
reg.Add(&renderableProvider{})
|
|
||||||
|
|
||||||
var renderables []provider.Renderable
|
|
||||||
for r := range iter {
|
|
||||||
renderables = append(renderables, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, renderables, 1)
|
|
||||||
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_Info_Good(t *testing.T) {
|
func TestRegistry_Info_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&fullProvider{})
|
reg.Add(&fullProvider{})
|
||||||
|
|
@ -244,59 +147,6 @@ func TestRegistry_Info_Good(t *testing.T) {
|
||||||
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_Info_Good_ProxyMetadata(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(provider.NewProxy(provider.ProxyConfig{
|
|
||||||
Name: "proxy",
|
|
||||||
BasePath: "/api/proxy",
|
|
||||||
Upstream: "http://127.0.0.1:9999",
|
|
||||||
SpecFile: "/tmp/proxy-openapi.json",
|
|
||||||
}))
|
|
||||||
|
|
||||||
infos := reg.Info()
|
|
||||||
require.Len(t, infos, 1)
|
|
||||||
|
|
||||||
info := infos[0]
|
|
||||||
assert.Equal(t, "proxy", info.Name)
|
|
||||||
assert.Equal(t, "/api/proxy", info.BasePath)
|
|
||||||
assert.Equal(t, "/tmp/proxy-openapi.json", info.SpecFile)
|
|
||||||
assert.Equal(t, "http://127.0.0.1:9999", info.Upstream)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_InfoIter_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&fullProvider{})
|
|
||||||
|
|
||||||
var infos []provider.ProviderInfo
|
|
||||||
for info := range reg.InfoIter() {
|
|
||||||
infos = append(infos, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Len(t, infos, 1)
|
|
||||||
info := infos[0]
|
|
||||||
assert.Equal(t, "full", info.Name)
|
|
||||||
assert.Equal(t, "/api/full", info.BasePath)
|
|
||||||
assert.Equal(t, []string{"stub.event"}, info.Channels)
|
|
||||||
require.NotNil(t, info.Element)
|
|
||||||
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_InfoIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&fullProvider{})
|
|
||||||
|
|
||||||
iter := reg.InfoIter()
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/later.json"})
|
|
||||||
|
|
||||||
var infos []provider.ProviderInfo
|
|
||||||
for info := range iter {
|
|
||||||
infos = append(infos, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Len(t, infos, 1)
|
|
||||||
assert.Equal(t, "full", infos[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_Iter_Good(t *testing.T) {
|
func TestRegistry_Iter_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{})
|
reg.Add(&stubProvider{})
|
||||||
|
|
@ -308,27 +158,3 @@ func TestRegistry_Iter_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
assert.Equal(t, 2, count)
|
assert.Equal(t, 2, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_SpecFiles_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&stubProvider{})
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/b.json"})
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
|
|
||||||
reg.Add(&specFileProvider{specFile: ""})
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"/tmp/a.yaml", "/tmp/b.json"}, reg.SpecFiles())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_SpecFilesIter_Good(t *testing.T) {
|
|
||||||
reg := provider.NewRegistry()
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/z.json"})
|
|
||||||
reg.Add(&specFileProvider{specFile: "/tmp/x.json"})
|
|
||||||
|
|
||||||
var files []string
|
|
||||||
for file := range reg.SpecFilesIter() {
|
|
||||||
files = append(files, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"/tmp/x.json", "/tmp/z.json"}, files)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,27 @@ func TestWithPprof_Good_CmdlineEndpointExists(t *testing.T) {
|
||||||
t.Fatalf("expected 200 for /debug/pprof/cmdline, got %d", resp.StatusCode)
|
t.Fatalf("expected 200 for /debug/pprof/cmdline, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithPprof_Ugly_DoubleRegistrationDoesNotPanic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("double WithPprof panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Registering pprof twice should not panic on engine construction.
|
||||||
|
engine, err := api.New(api.WithPprof(), api.WithPprof())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
engine.Handler().ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
216
ratelimit.go
216
ratelimit.go
|
|
@ -1,216 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
rateLimitCleanupInterval = time.Minute
|
|
||||||
rateLimitStaleAfter = 10 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type rateLimitStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buckets map[string]*rateLimitBucket
|
|
||||||
limit int
|
|
||||||
lastSweep time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type rateLimitBucket struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
tokens float64
|
|
||||||
last time.Time
|
|
||||||
lastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type rateLimitDecision struct {
|
|
||||||
allowed bool
|
|
||||||
retryAfter time.Duration
|
|
||||||
limit int
|
|
||||||
remaining int
|
|
||||||
resetAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRateLimitStore(limit int) *rateLimitStore {
|
|
||||||
now := time.Now()
|
|
||||||
return &rateLimitStore{
|
|
||||||
buckets: make(map[string]*rateLimitBucket),
|
|
||||||
limit: limit,
|
|
||||||
lastSweep: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *rateLimitStore) allow(key string) rateLimitDecision {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
bucket, ok := s.buckets[key]
|
|
||||||
if !ok || now.Sub(bucket.lastSeen) > rateLimitStaleAfter {
|
|
||||||
bucket = &rateLimitBucket{
|
|
||||||
tokens: float64(s.limit),
|
|
||||||
last: now,
|
|
||||||
lastSeen: now,
|
|
||||||
}
|
|
||||||
s.buckets[key] = bucket
|
|
||||||
} else {
|
|
||||||
bucket.lastSeen = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.Sub(s.lastSweep) >= rateLimitCleanupInterval {
|
|
||||||
for k, candidate := range s.buckets {
|
|
||||||
if now.Sub(candidate.lastSeen) > rateLimitStaleAfter {
|
|
||||||
delete(s.buckets, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.lastSweep = now
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
bucket.mu.Lock()
|
|
||||||
defer bucket.mu.Unlock()
|
|
||||||
|
|
||||||
elapsed := now.Sub(bucket.last)
|
|
||||||
if elapsed > 0 {
|
|
||||||
refill := elapsed.Seconds() * float64(s.limit)
|
|
||||||
if bucket.tokens+refill > float64(s.limit) {
|
|
||||||
bucket.tokens = float64(s.limit)
|
|
||||||
} else {
|
|
||||||
bucket.tokens += refill
|
|
||||||
}
|
|
||||||
bucket.last = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if bucket.tokens >= 1 {
|
|
||||||
bucket.tokens--
|
|
||||||
return rateLimitDecision{
|
|
||||||
allowed: true,
|
|
||||||
limit: s.limit,
|
|
||||||
remaining: int(math.Floor(bucket.tokens)),
|
|
||||||
resetAt: now.Add(timeUntilFull(bucket.tokens, s.limit)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deficit := 1 - bucket.tokens
|
|
||||||
wait := time.Duration(deficit / float64(s.limit) * float64(time.Second))
|
|
||||||
if wait <= 0 {
|
|
||||||
wait = time.Second / time.Duration(s.limit)
|
|
||||||
if wait <= 0 {
|
|
||||||
wait = time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rateLimitDecision{
|
|
||||||
allowed: false,
|
|
||||||
retryAfter: wait,
|
|
||||||
limit: s.limit,
|
|
||||||
remaining: 0,
|
|
||||||
resetAt: now.Add(wait),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rateLimitMiddleware(limit int) gin.HandlerFunc {
|
|
||||||
if limit <= 0 {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
store := newRateLimitStore(limit)
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
key := clientRateLimitKey(c)
|
|
||||||
decision := store.allow(key)
|
|
||||||
if !decision.allowed {
|
|
||||||
secs := int(decision.retryAfter / time.Second)
|
|
||||||
if decision.retryAfter%time.Second != 0 {
|
|
||||||
secs++
|
|
||||||
}
|
|
||||||
if secs < 1 {
|
|
||||||
secs = 1
|
|
||||||
}
|
|
||||||
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
|
|
||||||
c.Header("Retry-After", strconv.Itoa(secs))
|
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, Fail(
|
|
||||||
"rate_limit_exceeded",
|
|
||||||
"Too many requests",
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRateLimitHeaders(c *gin.Context, limit, remaining int, resetAt time.Time) {
|
|
||||||
if limit > 0 {
|
|
||||||
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
|
|
||||||
}
|
|
||||||
if remaining < 0 {
|
|
||||||
remaining = 0
|
|
||||||
}
|
|
||||||
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
|
||||||
if !resetAt.IsZero() {
|
|
||||||
reset := resetAt.Unix()
|
|
||||||
if reset <= time.Now().Unix() {
|
|
||||||
reset = time.Now().Add(time.Second).Unix()
|
|
||||||
}
|
|
||||||
c.Header("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeUntilFull(tokens float64, limit int) time.Duration {
|
|
||||||
if limit <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
missing := float64(limit) - tokens
|
|
||||||
if missing <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
seconds := missing / float64(limit)
|
|
||||||
if seconds <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return time.Duration(math.Ceil(seconds * float64(time.Second)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientRateLimitKey prefers caller-provided credentials for bucket
|
|
||||||
// isolation, then falls back to the network address.
|
|
||||||
func clientRateLimitKey(c *gin.Context) string {
|
|
||||||
if apiKey := strings.TrimSpace(c.GetHeader("X-API-Key")); apiKey != "" {
|
|
||||||
return "api_key:" + apiKey
|
|
||||||
}
|
|
||||||
if bearer := bearerTokenFromHeader(c.GetHeader("Authorization")); bearer != "" {
|
|
||||||
return "bearer:" + bearer
|
|
||||||
}
|
|
||||||
if ip := c.ClientIP(); ip != "" {
|
|
||||||
return "ip:" + ip
|
|
||||||
}
|
|
||||||
if c.Request != nil && c.Request.RemoteAddr != "" {
|
|
||||||
return "ip:" + c.Request.RemoteAddr
|
|
||||||
}
|
|
||||||
return "ip:unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func bearerTokenFromHeader(header string) string {
|
|
||||||
header = strings.TrimSpace(header)
|
|
||||||
if header == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(header, " ", 2)
|
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type rateLimitTestGroup struct{}
|
|
||||||
|
|
||||||
func (r *rateLimitTestGroup) Name() string { return "rate-limit" }
|
|
||||||
func (r *rateLimitTestGroup) BasePath() string { return "/rate" }
|
|
||||||
func (r *rateLimitTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|
||||||
rg.GET("/ping", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, api.OK("pong"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Good_AllowsBurstThenRejects(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(2))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req1.RemoteAddr = "203.0.113.10:1234"
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected first request to succeed, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
if got := w1.Header().Get("X-RateLimit-Limit"); got != "2" {
|
|
||||||
t.Fatalf("expected X-RateLimit-Limit=2, got %q", got)
|
|
||||||
}
|
|
||||||
if got := w1.Header().Get("X-RateLimit-Remaining"); got != "1" {
|
|
||||||
t.Fatalf("expected X-RateLimit-Remaining=1, got %q", got)
|
|
||||||
}
|
|
||||||
if got := w1.Header().Get("X-RateLimit-Reset"); got == "" {
|
|
||||||
t.Fatal("expected X-RateLimit-Reset on successful response")
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req2.RemoteAddr = "203.0.113.10:1234"
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected second request to succeed, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req3.RemoteAddr = "203.0.113.10:1234"
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if w3.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("expected third request to be rate limited, got %d", w3.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := w3.Header().Get("Retry-After"); got == "" {
|
|
||||||
t.Fatal("expected Retry-After header on 429 response")
|
|
||||||
}
|
|
||||||
if got := w3.Header().Get("X-RateLimit-Limit"); got != "2" {
|
|
||||||
t.Fatalf("expected X-RateLimit-Limit=2 on 429, got %q", got)
|
|
||||||
}
|
|
||||||
if got := w3.Header().Get("X-RateLimit-Remaining"); got != "0" {
|
|
||||||
t.Fatalf("expected X-RateLimit-Remaining=0 on 429, got %q", got)
|
|
||||||
}
|
|
||||||
if got := w3.Header().Get("X-RateLimit-Reset"); got == "" {
|
|
||||||
t.Fatal("expected X-RateLimit-Reset on 429 response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp api.Response[any]
|
|
||||||
if err := json.Unmarshal(w3.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Success {
|
|
||||||
t.Fatal("expected Success=false for rate limited response")
|
|
||||||
}
|
|
||||||
if resp.Error == nil || resp.Error.Code != "rate_limit_exceeded" {
|
|
||||||
t.Fatalf("expected rate_limit_exceeded error, got %+v", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Good_IsolatesPerIP(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(1))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req1.RemoteAddr = "203.0.113.10:1234"
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected first IP to succeed, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
if got := w1.Header().Get("X-RateLimit-Limit"); got != "1" {
|
|
||||||
t.Fatalf("expected X-RateLimit-Limit=1, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req2.RemoteAddr = "203.0.113.11:1234"
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected second IP to have its own bucket, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Good_IsolatesPerAPIKey(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(1))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req1.RemoteAddr = "203.0.113.20:1234"
|
|
||||||
req1.Header.Set("X-API-Key", "key-a")
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected first API key request to succeed, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req2.RemoteAddr = "203.0.113.20:1234"
|
|
||||||
req2.Header.Set("X-API-Key", "key-b")
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected second API key to have its own bucket, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req3.RemoteAddr = "203.0.113.20:1234"
|
|
||||||
req3.Header.Set("X-API-Key", "key-a")
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if w3.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("expected repeated API key to be rate limited, got %d", w3.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Good_UsesBearerTokenWhenPresent(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(1))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req1.RemoteAddr = "203.0.113.30:1234"
|
|
||||||
req1.Header.Set("Authorization", "Bearer token-a")
|
|
||||||
h.ServeHTTP(w1, req1)
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected first bearer token request to succeed, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req2.RemoteAddr = "203.0.113.30:1234"
|
|
||||||
req2.Header.Set("Authorization", "Bearer token-b")
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected second bearer token to have its own bucket, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req3.RemoteAddr = "203.0.113.30:1234"
|
|
||||||
req3.Header.Set("Authorization", "Bearer token-a")
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if w3.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("expected repeated bearer token to be rate limited, got %d", w3.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Good_RefillsOverTime(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(1))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req.RemoteAddr = "203.0.113.12:1234"
|
|
||||||
|
|
||||||
w1 := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w1, req.Clone(req.Context()))
|
|
||||||
if w1.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected first request to succeed, got %d", w1.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
|
||||||
req2 := req.Clone(req.Context())
|
|
||||||
req2.RemoteAddr = req.RemoteAddr
|
|
||||||
h.ServeHTTP(w2, req2)
|
|
||||||
if w2.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("expected second request to be rate limited, got %d", w2.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
|
||||||
|
|
||||||
w3 := httptest.NewRecorder()
|
|
||||||
req3 := req.Clone(req.Context())
|
|
||||||
req3.RemoteAddr = req.RemoteAddr
|
|
||||||
h.ServeHTTP(w3, req3)
|
|
||||||
if w3.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected bucket to refill after waiting, got %d", w3.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithRateLimit_Ugly_NonPositiveLimitDisablesMiddleware(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
e, _ := api.New(api.WithRateLimit(0))
|
|
||||||
e.Register(&rateLimitTestGroup{})
|
|
||||||
|
|
||||||
h := e.Handler()
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
|
|
||||||
req.RemoteAddr = "203.0.113.13:1234"
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected request %d to succeed with disabled limiter, got %d", i+1, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
response.go
60
response.go
|
|
@ -2,14 +2,7 @@
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
// Response is the standard envelope for all API responses.
|
// Response is the standard envelope for all API responses.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// resp := api.OK(map[string]any{"id": 42})
|
|
||||||
// resp.Success // true
|
|
||||||
type Response[T any] struct {
|
type Response[T any] struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data T `json:"data,omitempty"`
|
Data T `json:"data,omitempty"`
|
||||||
|
|
@ -18,10 +11,6 @@ type Response[T any] struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error describes a failed API request.
|
// Error describes a failed API request.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// err := api.Error{Code: "invalid_input", Message: "Name is required"}
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -29,10 +18,6 @@ type Error struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta carries pagination and request metadata.
|
// Meta carries pagination and request metadata.
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// meta := api.Meta{RequestID: "req_123", Duration: "12ms"}
|
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
RequestID string `json:"request_id,omitempty"`
|
RequestID string `json:"request_id,omitempty"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Duration string `json:"duration,omitempty"`
|
||||||
|
|
@ -43,9 +28,8 @@ type Meta struct {
|
||||||
|
|
||||||
// OK wraps data in a successful response envelope.
|
// OK wraps data in a successful response envelope.
|
||||||
//
|
//
|
||||||
// Example:
|
// c.JSON(http.StatusOK, api.OK(user))
|
||||||
//
|
// c.JSON(http.StatusOK, api.OK("healthy"))
|
||||||
// c.JSON(http.StatusOK, api.OK(map[string]any{"name": "status"}))
|
|
||||||
func OK[T any](data T) Response[T] {
|
func OK[T any](data T) Response[T] {
|
||||||
return Response[T]{
|
return Response[T]{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|
@ -55,9 +39,7 @@ func OK[T any](data T) Response[T] {
|
||||||
|
|
||||||
// Fail creates an error response with the given code and message.
|
// Fail creates an error response with the given code and message.
|
||||||
//
|
//
|
||||||
// Example:
|
// c.AbortWithStatusJSON(http.StatusUnauthorized, api.Fail("unauthorised", "token expired"))
|
||||||
//
|
|
||||||
// c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Name is required"))
|
|
||||||
func Fail(code, message string) Response[any] {
|
func Fail(code, message string) Response[any] {
|
||||||
return Response[any]{
|
return Response[any]{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|
@ -70,9 +52,7 @@ func Fail(code, message string) Response[any] {
|
||||||
|
|
||||||
// FailWithDetails creates an error response with additional detail payload.
|
// FailWithDetails creates an error response with additional detail payload.
|
||||||
//
|
//
|
||||||
// Example:
|
// c.JSON(http.StatusBadRequest, api.FailWithDetails("validation", "invalid input", fieldErrors))
|
||||||
//
|
|
||||||
// c.JSON(http.StatusBadRequest, api.FailWithDetails("invalid_input", "Name is required", map[string]any{"field": "name"}))
|
|
||||||
func FailWithDetails(code, message string, details any) Response[any] {
|
func FailWithDetails(code, message string, details any) Response[any] {
|
||||||
return Response[any]{
|
return Response[any]{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|
@ -86,9 +66,7 @@ func FailWithDetails(code, message string, details any) Response[any] {
|
||||||
|
|
||||||
// Paginated wraps data in a successful response with pagination metadata.
|
// Paginated wraps data in a successful response with pagination metadata.
|
||||||
//
|
//
|
||||||
// Example:
|
// c.JSON(http.StatusOK, api.Paginated(users, page, 20, totalCount))
|
||||||
//
|
|
||||||
// c.JSON(http.StatusOK, api.Paginated(items, 2, 50, 200))
|
|
||||||
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
||||||
return Response[T]{
|
return Response[T]{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|
@ -100,31 +78,3 @@ func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachRequestMeta merges request metadata into an existing response envelope.
|
|
||||||
// Existing pagination metadata is preserved; request_id and duration are added
|
|
||||||
// when available from the Gin context.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// resp = api.AttachRequestMeta(c, resp)
|
|
||||||
func AttachRequestMeta[T any](c *gin.Context, resp Response[T]) Response[T] {
|
|
||||||
meta := GetRequestMeta(c)
|
|
||||||
if meta == nil {
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Meta == nil {
|
|
||||||
resp.Meta = meta
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Meta.RequestID == "" {
|
|
||||||
resp.Meta.RequestID = meta.RequestID
|
|
||||||
}
|
|
||||||
if resp.Meta.Duration == "" {
|
|
||||||
resp.Meta.Duration = meta.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
|
||||||
277
response_meta.go
277
response_meta.go
|
|
@ -1,277 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// responseMetaRecorder buffers JSON responses so request metadata can be
|
|
||||||
// injected into the standard envelope before the body is written to the client.
|
|
||||||
type responseMetaRecorder struct {
|
|
||||||
gin.ResponseWriter
|
|
||||||
headers http.Header
|
|
||||||
body bytes.Buffer
|
|
||||||
status int
|
|
||||||
wroteHeader bool
|
|
||||||
committed bool
|
|
||||||
passthrough bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newResponseMetaRecorder(w gin.ResponseWriter) *responseMetaRecorder {
|
|
||||||
headers := make(http.Header)
|
|
||||||
for k, vals := range w.Header() {
|
|
||||||
headers[k] = append([]string(nil), vals...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &responseMetaRecorder{
|
|
||||||
ResponseWriter: w,
|
|
||||||
headers: headers,
|
|
||||||
status: http.StatusOK,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Header() http.Header {
|
|
||||||
return w.headers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) WriteHeader(code int) {
|
|
||||||
if w.passthrough {
|
|
||||||
w.status = code
|
|
||||||
w.wroteHeader = true
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.status = code
|
|
||||||
w.wroteHeader = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) WriteHeaderNow() {
|
|
||||||
if w.passthrough {
|
|
||||||
w.wroteHeader = true
|
|
||||||
w.ResponseWriter.WriteHeaderNow()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.wroteHeader = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
|
|
||||||
if w.passthrough {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(data)
|
|
||||||
}
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.body.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
|
|
||||||
if w.passthrough {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.WriteString(s)
|
|
||||||
}
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
return w.body.WriteString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Flush() {
|
|
||||||
if w.passthrough {
|
|
||||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.commit(true)
|
|
||||||
w.passthrough = true
|
|
||||||
|
|
||||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Status() int {
|
|
||||||
if w.wroteHeader {
|
|
||||||
return w.status
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.StatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Size() int {
|
|
||||||
return w.body.Len()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Written() bool {
|
|
||||||
return w.wroteHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
||||||
if w.passthrough {
|
|
||||||
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
||||||
return h.Hijack()
|
|
||||||
}
|
|
||||||
return nil, nil, io.ErrClosedPipe
|
|
||||||
}
|
|
||||||
|
|
||||||
w.wroteHeader = true
|
|
||||||
w.passthrough = true
|
|
||||||
|
|
||||||
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
||||||
return h.Hijack()
|
|
||||||
}
|
|
||||||
return nil, nil, io.ErrClosedPipe
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseMetaRecorder) commit(writeBody bool) {
|
|
||||||
if w.committed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for k := range w.ResponseWriter.Header() {
|
|
||||||
w.ResponseWriter.Header().Del(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, vals := range w.headers {
|
|
||||||
for _, v := range vals {
|
|
||||||
w.ResponseWriter.Header().Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.ResponseWriter.WriteHeader(w.Status())
|
|
||||||
if writeBody {
|
|
||||||
_, _ = w.ResponseWriter.Write(w.body.Bytes())
|
|
||||||
w.body.Reset()
|
|
||||||
}
|
|
||||||
w.committed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// responseMetaMiddleware injects request metadata into JSON envelope
|
|
||||||
// responses before they are written to the client.
|
|
||||||
func responseMetaMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if _, ok := c.Get(requestStartContextKey); !ok {
|
|
||||||
c.Set(requestStartContextKey, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder := newResponseMetaRecorder(c.Writer)
|
|
||||||
c.Writer = recorder
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
if recorder.passthrough {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body := recorder.body.Bytes()
|
|
||||||
if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get("Content-Type"), body) {
|
|
||||||
if refreshed := refreshResponseMetaBody(body, meta); refreshed != nil {
|
|
||||||
body = refreshed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder.body.Reset()
|
|
||||||
_, _ = recorder.body.Write(body)
|
|
||||||
recorder.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
||||||
recorder.commit(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshResponseMetaBody injects request metadata into a cached or buffered
|
|
||||||
// JSON envelope without disturbing existing pagination metadata.
|
|
||||||
func refreshResponseMetaBody(body []byte, meta *Meta) []byte {
|
|
||||||
if meta == nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload any
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
dec.UseNumber()
|
|
||||||
if err := dec.Decode(&payload); err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
var extra any
|
|
||||||
if err := dec.Decode(&extra); err != io.EOF {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
obj, ok := payload.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := obj["success"]; !ok {
|
|
||||||
if _, ok := obj["error"]; !ok {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current := map[string]any{}
|
|
||||||
if existing, ok := obj["meta"].(map[string]any); ok {
|
|
||||||
current = existing
|
|
||||||
}
|
|
||||||
|
|
||||||
if meta.RequestID != "" {
|
|
||||||
current["request_id"] = meta.RequestID
|
|
||||||
}
|
|
||||||
if meta.Duration != "" {
|
|
||||||
current["duration"] = meta.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
obj["meta"] = current
|
|
||||||
|
|
||||||
updated, err := json.Marshal(obj)
|
|
||||||
if err != nil {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldAttachResponseMeta(contentType string, body []byte) bool {
|
|
||||||
if !isJSONContentType(contentType) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed := bytes.TrimSpace(body)
|
|
||||||
return len(trimmed) > 0 && trimmed[0] == '{'
|
|
||||||
}
|
|
||||||
|
|
||||||
func isJSONContentType(contentType string) bool {
|
|
||||||
if strings.TrimSpace(contentType) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
||||||
if err != nil {
|
|
||||||
mediaType = strings.TrimSpace(contentType)
|
|
||||||
}
|
|
||||||
mediaType = strings.ToLower(mediaType)
|
|
||||||
|
|
||||||
return mediaType == "application/json" ||
|
|
||||||
strings.HasSuffix(mediaType, "+json") ||
|
|
||||||
strings.HasSuffix(mediaType, "/json")
|
|
||||||
}
|
|
||||||
|
|
@ -203,3 +203,26 @@ func TestPaginated_Good_JSONIncludesMeta(t *testing.T) {
|
||||||
t.Fatalf("expected total=50, got %v", meta["total"])
|
t.Fatalf("expected total=50, got %v", meta["total"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponse_Ugly_ZeroValuesDontPanic(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("Response zero value caused panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// A zero-value Response[any] should be safe to use.
|
||||||
|
var zeroResponse api.Response[any]
|
||||||
|
if zeroResponse.Success {
|
||||||
|
t.Fatal("expected zero-value Success=false")
|
||||||
|
}
|
||||||
|
if zeroResponse.Error != nil {
|
||||||
|
t.Fatal("expected nil Error in zero value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginated with zero values should not panic.
|
||||||
|
paginated := api.Paginated[[]string](nil, 0, 0, 0)
|
||||||
|
if !paginated.Success {
|
||||||
|
t.Fatal("expected Paginated to return Success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
// RuntimeConfig captures the engine's current runtime-facing configuration in
|
|
||||||
// a single snapshot.
|
|
||||||
//
|
|
||||||
// It groups the existing Swagger, transport, GraphQL, cache, and i18n snapshots
|
|
||||||
// so callers can inspect the active engine surface without joining multiple
|
|
||||||
// method results themselves.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.RuntimeConfig()
|
|
||||||
type RuntimeConfig struct {
|
|
||||||
Swagger SwaggerConfig
|
|
||||||
Transport TransportConfig
|
|
||||||
GraphQL GraphQLConfig
|
|
||||||
Cache CacheConfig
|
|
||||||
I18n I18nConfig
|
|
||||||
Authentik AuthentikConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeConfig returns a stable snapshot of the engine's current runtime
|
|
||||||
// configuration.
|
|
||||||
//
|
|
||||||
// The result clones the underlying snapshots so callers can safely retain or
|
|
||||||
// modify the returned value.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.RuntimeConfig()
|
|
||||||
func (e *Engine) RuntimeConfig() RuntimeConfig {
|
|
||||||
if e == nil {
|
|
||||||
return RuntimeConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RuntimeConfig{
|
|
||||||
Swagger: e.SwaggerConfig(),
|
|
||||||
Transport: e.TransportConfig(),
|
|
||||||
GraphQL: e.GraphQLConfig(),
|
|
||||||
Cache: e.CacheConfig(),
|
|
||||||
I18n: e.I18nConfig(),
|
|
||||||
Authentik: e.AuthentikConfig(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
servers.go
53
servers.go
|
|
@ -1,53 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// normaliseServers trims whitespace, removes empty entries, and preserves
|
|
||||||
// the first occurrence of each server URL.
|
|
||||||
func normaliseServers(servers []string) []string {
|
|
||||||
if len(servers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned := make([]string, 0, len(servers))
|
|
||||||
seen := make(map[string]struct{}, len(servers))
|
|
||||||
|
|
||||||
for _, server := range servers {
|
|
||||||
server = normaliseServer(server)
|
|
||||||
if server == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[server]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[server] = struct{}{}
|
|
||||||
cleaned = append(cleaned, server)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cleaned) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
// normaliseServer trims surrounding whitespace and removes a trailing slash
|
|
||||||
// from non-root server URLs so equivalent metadata collapses to one entry.
|
|
||||||
func normaliseServer(server string) string {
|
|
||||||
server = strings.TrimSpace(server)
|
|
||||||
if server == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if server == "/" {
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
server = strings.TrimRight(server, "/")
|
|
||||||
if server == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SwaggerConfig captures the configured Swagger/OpenAPI metadata for an Engine.
|
|
||||||
//
|
|
||||||
// It is intentionally small and serialisable so callers can inspect the active
|
|
||||||
// documentation surface without rebuilding an OpenAPI document.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := api.SwaggerConfig{Title: "Service", Summary: "Public API"}
|
|
||||||
type SwaggerConfig struct {
|
|
||||||
Enabled bool
|
|
||||||
Path string
|
|
||||||
Title string
|
|
||||||
Summary string
|
|
||||||
Description string
|
|
||||||
Version string
|
|
||||||
TermsOfService string
|
|
||||||
ContactName string
|
|
||||||
ContactURL string
|
|
||||||
ContactEmail string
|
|
||||||
Servers []string
|
|
||||||
LicenseName string
|
|
||||||
LicenseURL string
|
|
||||||
SecuritySchemes map[string]any
|
|
||||||
ExternalDocsDescription string
|
|
||||||
ExternalDocsURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAPISpecBuilder returns a SpecBuilder populated from the engine's current
|
|
||||||
// Swagger, transport, cache, i18n, and Authentik metadata.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// builder := engine.OpenAPISpecBuilder()
|
|
||||||
func (e *Engine) OpenAPISpecBuilder() *SpecBuilder {
|
|
||||||
if e == nil {
|
|
||||||
return &SpecBuilder{}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := e.RuntimeConfig()
|
|
||||||
builder := &SpecBuilder{
|
|
||||||
Title: runtime.Swagger.Title,
|
|
||||||
Summary: runtime.Swagger.Summary,
|
|
||||||
Description: runtime.Swagger.Description,
|
|
||||||
Version: runtime.Swagger.Version,
|
|
||||||
SwaggerEnabled: runtime.Swagger.Enabled,
|
|
||||||
TermsOfService: runtime.Swagger.TermsOfService,
|
|
||||||
ContactName: runtime.Swagger.ContactName,
|
|
||||||
ContactURL: runtime.Swagger.ContactURL,
|
|
||||||
ContactEmail: runtime.Swagger.ContactEmail,
|
|
||||||
Servers: slices.Clone(runtime.Swagger.Servers),
|
|
||||||
LicenseName: runtime.Swagger.LicenseName,
|
|
||||||
LicenseURL: runtime.Swagger.LicenseURL,
|
|
||||||
SecuritySchemes: cloneSecuritySchemes(runtime.Swagger.SecuritySchemes),
|
|
||||||
ExternalDocsDescription: runtime.Swagger.ExternalDocsDescription,
|
|
||||||
ExternalDocsURL: runtime.Swagger.ExternalDocsURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.SwaggerPath = runtime.Transport.SwaggerPath
|
|
||||||
builder.GraphQLEnabled = runtime.GraphQL.Enabled
|
|
||||||
builder.GraphQLPath = runtime.GraphQL.Path
|
|
||||||
builder.GraphQLPlayground = runtime.GraphQL.Playground
|
|
||||||
builder.GraphQLPlaygroundPath = runtime.GraphQL.PlaygroundPath
|
|
||||||
builder.WSPath = runtime.Transport.WSPath
|
|
||||||
builder.WSEnabled = runtime.Transport.WSEnabled
|
|
||||||
builder.SSEPath = runtime.Transport.SSEPath
|
|
||||||
builder.SSEEnabled = runtime.Transport.SSEEnabled
|
|
||||||
builder.PprofEnabled = runtime.Transport.PprofEnabled
|
|
||||||
builder.ExpvarEnabled = runtime.Transport.ExpvarEnabled
|
|
||||||
|
|
||||||
builder.CacheEnabled = runtime.Cache.Enabled
|
|
||||||
if runtime.Cache.TTL > 0 {
|
|
||||||
builder.CacheTTL = runtime.Cache.TTL.String()
|
|
||||||
}
|
|
||||||
builder.CacheMaxEntries = runtime.Cache.MaxEntries
|
|
||||||
builder.CacheMaxBytes = runtime.Cache.MaxBytes
|
|
||||||
|
|
||||||
builder.I18nDefaultLocale = runtime.I18n.DefaultLocale
|
|
||||||
builder.I18nSupportedLocales = slices.Clone(runtime.I18n.Supported)
|
|
||||||
builder.AuthentikIssuer = runtime.Authentik.Issuer
|
|
||||||
builder.AuthentikClientID = runtime.Authentik.ClientID
|
|
||||||
builder.AuthentikTrustedProxy = runtime.Authentik.TrustedProxy
|
|
||||||
builder.AuthentikPublicPaths = slices.Clone(runtime.Authentik.PublicPaths)
|
|
||||||
|
|
||||||
return builder
|
|
||||||
}
|
|
||||||
|
|
||||||
// SwaggerConfig returns the currently configured Swagger metadata for the engine.
|
|
||||||
//
|
|
||||||
// The result snapshots the Engine state at call time and clones slices/maps so
|
|
||||||
// callers can safely reuse or modify the returned value.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// cfg := engine.SwaggerConfig()
|
|
||||||
func (e *Engine) SwaggerConfig() SwaggerConfig {
|
|
||||||
if e == nil {
|
|
||||||
return SwaggerConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := SwaggerConfig{
|
|
||||||
Enabled: e.swaggerEnabled,
|
|
||||||
Title: e.swaggerTitle,
|
|
||||||
Summary: e.swaggerSummary,
|
|
||||||
Description: e.swaggerDesc,
|
|
||||||
Version: e.swaggerVersion,
|
|
||||||
TermsOfService: e.swaggerTermsOfService,
|
|
||||||
ContactName: e.swaggerContactName,
|
|
||||||
ContactURL: e.swaggerContactURL,
|
|
||||||
ContactEmail: e.swaggerContactEmail,
|
|
||||||
Servers: slices.Clone(e.swaggerServers),
|
|
||||||
LicenseName: e.swaggerLicenseName,
|
|
||||||
LicenseURL: e.swaggerLicenseURL,
|
|
||||||
SecuritySchemes: cloneSecuritySchemes(e.swaggerSecuritySchemes),
|
|
||||||
ExternalDocsDescription: e.swaggerExternalDocsDescription,
|
|
||||||
ExternalDocsURL: e.swaggerExternalDocsURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(e.swaggerPath) != "" {
|
|
||||||
cfg.Path = normaliseSwaggerPath(e.swaggerPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSecuritySchemes(schemes map[string]any) map[string]any {
|
|
||||||
if len(schemes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]any, len(schemes))
|
|
||||||
for name, scheme := range schemes {
|
|
||||||
out[name] = cloneOpenAPIValue(scheme)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneRouteDescription(rd RouteDescription) RouteDescription {
|
|
||||||
out := rd
|
|
||||||
|
|
||||||
out.Tags = slices.Clone(rd.Tags)
|
|
||||||
out.Security = cloneSecurityRequirements(rd.Security)
|
|
||||||
out.Parameters = cloneParameterDescriptions(rd.Parameters)
|
|
||||||
out.RequestBody = cloneOpenAPIObject(rd.RequestBody)
|
|
||||||
out.RequestExample = cloneOpenAPIValue(rd.RequestExample)
|
|
||||||
out.Response = cloneOpenAPIObject(rd.Response)
|
|
||||||
out.ResponseExample = cloneOpenAPIValue(rd.ResponseExample)
|
|
||||||
out.ResponseHeaders = cloneStringMap(rd.ResponseHeaders)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneParameterDescriptions(params []ParameterDescription) []ParameterDescription {
|
|
||||||
if params == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(params) == 0 {
|
|
||||||
return []ParameterDescription{}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]ParameterDescription, len(params))
|
|
||||||
for i, param := range params {
|
|
||||||
out[i] = param
|
|
||||||
out[i].Schema = cloneOpenAPIObject(param.Schema)
|
|
||||||
out[i].Example = cloneOpenAPIValue(param.Example)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSecurityRequirements(security []map[string][]string) []map[string][]string {
|
|
||||||
if security == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(security) == 0 {
|
|
||||||
return []map[string][]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]map[string][]string, len(security))
|
|
||||||
for i, requirement := range security {
|
|
||||||
if len(requirement) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cloned := make(map[string][]string, len(requirement))
|
|
||||||
for name, scopes := range requirement {
|
|
||||||
cloned[name] = slices.Clone(scopes)
|
|
||||||
}
|
|
||||||
out[i] = cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneOpenAPIObject(v map[string]any) map[string]any {
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(v) == 0 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cloned, _ := cloneOpenAPIValue(v).(map[string]any)
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringMap(v map[string]string) map[string]string {
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(v) == 0 {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]string, len(v))
|
|
||||||
for key, value := range v {
|
|
||||||
out[key] = value
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloneOpenAPIValue recursively copies JSON-like OpenAPI values so callers can
|
|
||||||
// safely retain and reuse their original maps after configuring an engine.
|
|
||||||
func cloneOpenAPIValue(v any) any {
|
|
||||||
switch value := v.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
out := make(map[string]any, len(value))
|
|
||||||
for k, nested := range value {
|
|
||||||
out[k] = cloneOpenAPIValue(nested)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []any:
|
|
||||||
out := make([]any, len(value))
|
|
||||||
for i, nested := range value {
|
|
||||||
out[i] = cloneOpenAPIValue(nested)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
rv := reflect.ValueOf(v)
|
|
||||||
if !rv.IsValid() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rv.Kind() {
|
|
||||||
case reflect.Map:
|
|
||||||
out := reflect.MakeMapWithSize(rv.Type(), rv.Len())
|
|
||||||
for _, key := range rv.MapKeys() {
|
|
||||||
cloned := cloneOpenAPIValue(rv.MapIndex(key).Interface())
|
|
||||||
if cloned == nil {
|
|
||||||
out.SetMapIndex(key, reflect.Zero(rv.Type().Elem()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.SetMapIndex(key, reflect.ValueOf(cloned))
|
|
||||||
}
|
|
||||||
return out.Interface()
|
|
||||||
case reflect.Slice:
|
|
||||||
if rv.IsNil() {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
|
||||||
for i := 0; i < rv.Len(); i++ {
|
|
||||||
cloned := cloneOpenAPIValue(rv.Index(i).Interface())
|
|
||||||
if cloned == nil {
|
|
||||||
out.Index(i).Set(reflect.Zero(rv.Type().Elem()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out.Index(i).Set(reflect.ValueOf(cloned))
|
|
||||||
}
|
|
||||||
return out.Interface()
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestEngine_SwaggerConfig_Good_NormalisesPathAtSnapshot(t *testing.T) {
|
|
||||||
e := &Engine{
|
|
||||||
swaggerPath: " /docs/ ",
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.SwaggerConfig()
|
|
||||||
if cfg.Path != "/docs" {
|
|
||||||
t.Fatalf("expected normalised Swagger path /docs, got %q", cfg.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_TransportConfig_Good_NormalisesGraphQLPathAtSnapshot(t *testing.T) {
|
|
||||||
e := &Engine{
|
|
||||||
graphql: &graphqlConfig{
|
|
||||||
path: " /gql/ ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.TransportConfig()
|
|
||||||
if cfg.GraphQLPath != "/gql" {
|
|
||||||
t.Fatalf("expected normalised GraphQL path /gql, got %q", cfg.GraphQLPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,647 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
broker := api.NewSSEBroker()
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
|
|
||||||
api.WithSwaggerSummary("Engine overview"),
|
|
||||||
api.WithSwaggerPath("/docs"),
|
|
||||||
api.WithSwaggerTermsOfService("https://example.com/terms"),
|
|
||||||
api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"),
|
|
||||||
api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"),
|
|
||||||
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
|
|
||||||
api.WithSwaggerSecuritySchemes(map[string]any{
|
|
||||||
"apiKeyAuth": map[string]any{
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "header",
|
|
||||||
"name": "X-API-Key",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
|
|
||||||
api.WithCacheLimits(5*time.Minute, 42, 8192),
|
|
||||||
api.WithI18n(api.I18nConfig{
|
|
||||||
DefaultLocale: "en-GB",
|
|
||||||
Supported: []string{"en-GB", "fr"},
|
|
||||||
}),
|
|
||||||
api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
Issuer: "https://auth.example.com",
|
|
||||||
ClientID: "core-client",
|
|
||||||
TrustedProxy: true,
|
|
||||||
PublicPaths: []string{" /public/ ", "docs", "/public"},
|
|
||||||
}),
|
|
||||||
api.WithWSPath("/socket"),
|
|
||||||
api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
|
|
||||||
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
|
||||||
api.WithSSE(broker),
|
|
||||||
api.WithSSEPath("/events"),
|
|
||||||
api.WithPprof(),
|
|
||||||
api.WithExpvar(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, ok := spec["info"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected info object in generated spec")
|
|
||||||
}
|
|
||||||
if info["title"] != "Engine API" {
|
|
||||||
t.Fatalf("expected title Engine API, got %v", info["title"])
|
|
||||||
}
|
|
||||||
if info["description"] != "Engine metadata" {
|
|
||||||
t.Fatalf("expected description Engine metadata, got %v", info["description"])
|
|
||||||
}
|
|
||||||
if info["version"] != "2.0.0" {
|
|
||||||
t.Fatalf("expected version 2.0.0, got %v", info["version"])
|
|
||||||
}
|
|
||||||
if info["summary"] != "Engine overview" {
|
|
||||||
t.Fatalf("expected summary Engine overview, got %v", info["summary"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := spec["x-swagger-ui-path"]; got != "/docs" {
|
|
||||||
t.Fatalf("expected x-swagger-ui-path=/docs, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-swagger-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-swagger-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-graphql-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-graphql-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-graphql-path"]; got != "/gql" {
|
|
||||||
t.Fatalf("expected x-graphql-path=/gql, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-graphql-playground"]; got != true {
|
|
||||||
t.Fatalf("expected x-graphql-playground=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-graphql-playground-path"]; got != "/gql/playground" {
|
|
||||||
t.Fatalf("expected x-graphql-playground-path=/gql/playground, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-ws-path"]; got != "/socket" {
|
|
||||||
t.Fatalf("expected x-ws-path=/socket, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-ws-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-ws-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-sse-path"]; got != "/events" {
|
|
||||||
t.Fatalf("expected x-sse-path=/events, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-sse-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-sse-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-pprof-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-pprof-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-expvar-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-expvar-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-cache-enabled"]; got != true {
|
|
||||||
t.Fatalf("expected x-cache-enabled=true, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-cache-ttl"]; got != "5m0s" {
|
|
||||||
t.Fatalf("expected x-cache-ttl=5m0s, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-cache-max-entries"]; got != float64(42) {
|
|
||||||
t.Fatalf("expected x-cache-max-entries=42, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-cache-max-bytes"]; got != float64(8192) {
|
|
||||||
t.Fatalf("expected x-cache-max-bytes=8192, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-i18n-default-locale"]; got != "en-GB" {
|
|
||||||
t.Fatalf("expected x-i18n-default-locale=en-GB, got %v", got)
|
|
||||||
}
|
|
||||||
locales, ok := spec["x-i18n-supported-locales"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected x-i18n-supported-locales array, got %T", spec["x-i18n-supported-locales"])
|
|
||||||
}
|
|
||||||
if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" {
|
|
||||||
t.Fatalf("expected supported locales [en-GB fr], got %v", locales)
|
|
||||||
}
|
|
||||||
if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" {
|
|
||||||
t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-authentik-client-id"]; got != "core-client" {
|
|
||||||
t.Fatalf("expected x-authentik-client-id=core-client, got %v", got)
|
|
||||||
}
|
|
||||||
if got := spec["x-authentik-trusted-proxy"]; got != true {
|
|
||||||
t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got)
|
|
||||||
}
|
|
||||||
publicPaths, ok := spec["x-authentik-public-paths"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"])
|
|
||||||
}
|
|
||||||
if len(publicPaths) != 4 || publicPaths[0] != "/health" || publicPaths[1] != "/swagger" || publicPaths[2] != "/docs" || publicPaths[3] != "/public" {
|
|
||||||
t.Fatalf("expected public paths [/health /swagger /docs /public], got %v", publicPaths)
|
|
||||||
}
|
|
||||||
|
|
||||||
contact, ok := info["contact"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected contact metadata in generated spec")
|
|
||||||
}
|
|
||||||
if contact["name"] != "API Support" {
|
|
||||||
t.Fatalf("expected contact name API Support, got %v", contact["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
license, ok := info["license"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected licence metadata in generated spec")
|
|
||||||
}
|
|
||||||
if license["name"] != "EUPL-1.2" {
|
|
||||||
t.Fatalf("expected licence name EUPL-1.2, got %v", license["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if info["termsOfService"] != "https://example.com/terms" {
|
|
||||||
t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"])
|
|
||||||
}
|
|
||||||
|
|
||||||
securitySchemes, ok := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected securitySchemes metadata in generated spec")
|
|
||||||
}
|
|
||||||
apiKeyAuth, ok := securitySchemes["apiKeyAuth"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected apiKeyAuth security scheme in generated spec")
|
|
||||||
}
|
|
||||||
if apiKeyAuth["type"] != "apiKey" {
|
|
||||||
t.Fatalf("expected apiKeyAuth.type=apiKey, got %v", apiKeyAuth["type"])
|
|
||||||
}
|
|
||||||
if apiKeyAuth["in"] != "header" {
|
|
||||||
t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"])
|
|
||||||
}
|
|
||||||
if apiKeyAuth["name"] != "X-API-Key" {
|
|
||||||
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
externalDocs, ok := spec["externalDocs"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected externalDocs metadata in generated spec")
|
|
||||||
}
|
|
||||||
if externalDocs["url"] != "https://example.com/docs" {
|
|
||||||
t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"])
|
|
||||||
}
|
|
||||||
|
|
||||||
servers, ok := spec["servers"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected servers array in generated spec, got %T", spec["servers"])
|
|
||||||
}
|
|
||||||
if len(servers) != 2 {
|
|
||||||
t.Fatalf("expected 2 normalised servers, got %d", len(servers))
|
|
||||||
}
|
|
||||||
if servers[0].(map[string]any)["url"] != "https://api.example.com" {
|
|
||||||
t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0])
|
|
||||||
}
|
|
||||||
if servers[1].(map[string]any)["url"] != "/" {
|
|
||||||
t.Fatalf("expected second server to be /, got %v", servers[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
paths, ok := spec["paths"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected paths object in generated spec, got %T", spec["paths"])
|
|
||||||
}
|
|
||||||
if _, ok := paths["/gql"]; !ok {
|
|
||||||
t.Fatal("expected GraphQL path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
if _, ok := paths["/gql/playground"]; !ok {
|
|
||||||
t.Fatal("expected GraphQL playground path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
if _, ok := paths["/socket"]; !ok {
|
|
||||||
t.Fatal("expected custom WebSocket path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
if _, ok := paths["/events"]; !ok {
|
|
||||||
t.Fatal("expected SSE path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
if _, ok := paths["/debug/pprof"]; !ok {
|
|
||||||
t.Fatal("expected pprof path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
if _, ok := paths["/debug/vars"]; !ok {
|
|
||||||
t.Fatal("expected expvar path from engine metadata in generated spec")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
|
|
||||||
api.WithSwaggerSummary("Engine overview"),
|
|
||||||
api.WithSwaggerTermsOfService("https://example.com/terms"),
|
|
||||||
api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"),
|
|
||||||
api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"),
|
|
||||||
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
|
|
||||||
api.WithSwaggerSecuritySchemes(map[string]any{
|
|
||||||
"apiKeyAuth": map[string]any{
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "header",
|
|
||||||
"name": "X-API-Key",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.SwaggerConfig()
|
|
||||||
if !cfg.Enabled {
|
|
||||||
t.Fatal("expected Swagger to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.Path != "" {
|
|
||||||
t.Fatalf("expected empty Swagger path when none is configured, got %q", cfg.Path)
|
|
||||||
}
|
|
||||||
if cfg.Title != "Engine API" {
|
|
||||||
t.Fatalf("expected title Engine API, got %q", cfg.Title)
|
|
||||||
}
|
|
||||||
if cfg.Description != "Engine metadata" {
|
|
||||||
t.Fatalf("expected description Engine metadata, got %q", cfg.Description)
|
|
||||||
}
|
|
||||||
if cfg.Version != "2.0.0" {
|
|
||||||
t.Fatalf("expected version 2.0.0, got %q", cfg.Version)
|
|
||||||
}
|
|
||||||
if cfg.Summary != "Engine overview" {
|
|
||||||
t.Fatalf("expected summary Engine overview, got %q", cfg.Summary)
|
|
||||||
}
|
|
||||||
if cfg.TermsOfService != "https://example.com/terms" {
|
|
||||||
t.Fatalf("expected termsOfService to be preserved, got %q", cfg.TermsOfService)
|
|
||||||
}
|
|
||||||
if cfg.ContactName != "API Support" {
|
|
||||||
t.Fatalf("expected contact name API Support, got %q", cfg.ContactName)
|
|
||||||
}
|
|
||||||
if cfg.LicenseName != "EUPL-1.2" {
|
|
||||||
t.Fatalf("expected licence name EUPL-1.2, got %q", cfg.LicenseName)
|
|
||||||
}
|
|
||||||
if cfg.ExternalDocsURL != "https://example.com/docs" {
|
|
||||||
t.Fatalf("expected external docs URL https://example.com/docs, got %q", cfg.ExternalDocsURL)
|
|
||||||
}
|
|
||||||
if len(cfg.Servers) != 2 {
|
|
||||||
t.Fatalf("expected 2 normalised servers, got %d", len(cfg.Servers))
|
|
||||||
}
|
|
||||||
if cfg.Servers[0] != "https://api.example.com" {
|
|
||||||
t.Fatalf("expected first server to be https://api.example.com, got %q", cfg.Servers[0])
|
|
||||||
}
|
|
||||||
if cfg.Servers[1] != "/" {
|
|
||||||
t.Fatalf("expected second server to be /, got %q", cfg.Servers[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
cfgWithPath, err := api.New(
|
|
||||||
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
|
|
||||||
api.WithSwaggerPath("/docs"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
snap := cfgWithPath.SwaggerConfig()
|
|
||||||
if snap.Path != "/docs" {
|
|
||||||
t.Fatalf("expected Swagger path /docs, got %q", snap.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKeyAuth, ok := cfg.SecuritySchemes["apiKeyAuth"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected apiKeyAuth security scheme in Swagger config")
|
|
||||||
}
|
|
||||||
if apiKeyAuth["name"] != "X-API-Key" {
|
|
||||||
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Servers[0] = "https://mutated.example.com"
|
|
||||||
apiKeyAuth["name"] = "Changed"
|
|
||||||
|
|
||||||
reshot := e.SwaggerConfig()
|
|
||||||
if reshot.Servers[0] != "https://api.example.com" {
|
|
||||||
t.Fatalf("expected engine servers to be cloned, got %q", reshot.Servers[0])
|
|
||||||
}
|
|
||||||
reshotScheme, ok := reshot.SecuritySchemes["apiKeyAuth"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected apiKeyAuth security scheme in cloned Swagger config")
|
|
||||||
}
|
|
||||||
if reshotScheme["name"] != "X-API-Key" {
|
|
||||||
t.Fatalf("expected cloned security scheme name X-API-Key, got %v", reshotScheme["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_SwaggerConfigTrimsRuntimeMetadata(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger(" Engine API ", " Engine metadata ", " 2.0.0 "),
|
|
||||||
api.WithSwaggerSummary(" Engine overview "),
|
|
||||||
api.WithSwaggerTermsOfService(" https://example.com/terms "),
|
|
||||||
api.WithSwaggerContact(" API Support ", " https://example.com/support ", " support@example.com "),
|
|
||||||
api.WithSwaggerLicense(" EUPL-1.2 ", " https://eupl.eu/1.2/en/ "),
|
|
||||||
api.WithSwaggerExternalDocs(" Developer guide ", " https://example.com/docs "),
|
|
||||||
api.WithAuthentik(api.AuthentikConfig{
|
|
||||||
Issuer: " https://auth.example.com ",
|
|
||||||
ClientID: " core-client ",
|
|
||||||
TrustedProxy: true,
|
|
||||||
PublicPaths: []string{" /public/ ", " docs ", "/public"},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
swagger := e.SwaggerConfig()
|
|
||||||
if swagger.Title != "Engine API" {
|
|
||||||
t.Fatalf("expected trimmed title Engine API, got %q", swagger.Title)
|
|
||||||
}
|
|
||||||
if swagger.Description != "Engine metadata" {
|
|
||||||
t.Fatalf("expected trimmed description Engine metadata, got %q", swagger.Description)
|
|
||||||
}
|
|
||||||
if swagger.Version != "2.0.0" {
|
|
||||||
t.Fatalf("expected trimmed version 2.0.0, got %q", swagger.Version)
|
|
||||||
}
|
|
||||||
if swagger.Summary != "Engine overview" {
|
|
||||||
t.Fatalf("expected trimmed summary Engine overview, got %q", swagger.Summary)
|
|
||||||
}
|
|
||||||
if swagger.TermsOfService != "https://example.com/terms" {
|
|
||||||
t.Fatalf("expected trimmed termsOfService, got %q", swagger.TermsOfService)
|
|
||||||
}
|
|
||||||
if swagger.ContactName != "API Support" || swagger.ContactURL != "https://example.com/support" || swagger.ContactEmail != "support@example.com" {
|
|
||||||
t.Fatalf("expected trimmed contact metadata, got %+v", swagger)
|
|
||||||
}
|
|
||||||
if swagger.LicenseName != "EUPL-1.2" || swagger.LicenseURL != "https://eupl.eu/1.2/en/" {
|
|
||||||
t.Fatalf("expected trimmed licence metadata, got %+v", swagger)
|
|
||||||
}
|
|
||||||
if swagger.ExternalDocsDescription != "Developer guide" || swagger.ExternalDocsURL != "https://example.com/docs" {
|
|
||||||
t.Fatalf("expected trimmed external docs metadata, got %+v", swagger)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := e.AuthentikConfig()
|
|
||||||
if auth.Issuer != "https://auth.example.com" {
|
|
||||||
t.Fatalf("expected trimmed issuer, got %q", auth.Issuer)
|
|
||||||
}
|
|
||||||
if auth.ClientID != "core-client" {
|
|
||||||
t.Fatalf("expected trimmed client ID, got %q", auth.ClientID)
|
|
||||||
}
|
|
||||||
if want := []string{"/public", "/docs"}; !slices.Equal(auth.PublicPaths, want) {
|
|
||||||
t.Fatalf("expected trimmed public paths %v, got %v", want, auth.PublicPaths)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, ok := spec["info"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected info object in generated spec")
|
|
||||||
}
|
|
||||||
if info["title"] != "Engine API" || info["description"] != "Engine metadata" || info["version"] != "2.0.0" || info["summary"] != "Engine overview" {
|
|
||||||
t.Fatalf("expected trimmed OpenAPI info block, got %+v", info)
|
|
||||||
}
|
|
||||||
if info["termsOfService"] != "https://example.com/terms" {
|
|
||||||
t.Fatalf("expected trimmed termsOfService in spec, got %v", info["termsOfService"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_TransportConfigCarriesEngineMetadata(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
broker := api.NewSSEBroker()
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
|
|
||||||
api.WithSwaggerPath("/docs"),
|
|
||||||
api.WithWSPath("/socket"),
|
|
||||||
api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
|
|
||||||
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
|
||||||
api.WithSSE(broker),
|
|
||||||
api.WithSSEPath("/events"),
|
|
||||||
api.WithPprof(),
|
|
||||||
api.WithExpvar(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.TransportConfig()
|
|
||||||
if !cfg.SwaggerEnabled {
|
|
||||||
t.Fatal("expected Swagger to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.SwaggerPath != "/docs" {
|
|
||||||
t.Fatalf("expected swagger path /docs, got %q", cfg.SwaggerPath)
|
|
||||||
}
|
|
||||||
if cfg.GraphQLPath != "/gql" {
|
|
||||||
t.Fatalf("expected graphql path /gql, got %q", cfg.GraphQLPath)
|
|
||||||
}
|
|
||||||
if !cfg.GraphQLEnabled {
|
|
||||||
t.Fatal("expected GraphQL to be enabled")
|
|
||||||
}
|
|
||||||
if !cfg.GraphQLPlayground {
|
|
||||||
t.Fatal("expected GraphQL playground to be enabled")
|
|
||||||
}
|
|
||||||
if !cfg.WSEnabled {
|
|
||||||
t.Fatal("expected WebSocket to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.WSPath != "/socket" {
|
|
||||||
t.Fatalf("expected ws path /socket, got %q", cfg.WSPath)
|
|
||||||
}
|
|
||||||
if !cfg.SSEEnabled {
|
|
||||||
t.Fatal("expected SSE to be enabled")
|
|
||||||
}
|
|
||||||
if cfg.SSEPath != "/events" {
|
|
||||||
t.Fatalf("expected sse path /events, got %q", cfg.SSEPath)
|
|
||||||
}
|
|
||||||
if !cfg.PprofEnabled {
|
|
||||||
t.Fatal("expected pprof to be enabled")
|
|
||||||
}
|
|
||||||
if !cfg.ExpvarEnabled {
|
|
||||||
t.Fatal("expected expvar to be enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_TransportConfigReportsDisabledSwaggerWithoutUI(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithSwaggerPath("/docs"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := e.TransportConfig()
|
|
||||||
if cfg.SwaggerEnabled {
|
|
||||||
t.Fatal("expected Swagger to remain disabled when only the path is configured")
|
|
||||||
}
|
|
||||||
if cfg.SwaggerPath != "/docs" {
|
|
||||||
t.Fatalf("expected swagger path /docs, got %q", cfg.SwaggerPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithSwagger("Engine API", "Engine metadata", "2.0.0"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := spec["x-swagger-ui-path"]; got != "/swagger" {
|
|
||||||
t.Fatalf("expected default x-swagger-ui-path=/swagger, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderCarriesExplicitSwaggerPathWithoutUI(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithSwaggerPath("/docs"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := spec["x-swagger-ui-path"]; got != "/docs" {
|
|
||||||
t.Fatalf("expected explicit x-swagger-ui-path=/docs, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredWSPathWithoutHandler(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithWSPath("/socket"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := spec["x-ws-path"]; got != "/socket" {
|
|
||||||
t.Fatalf("expected x-ws-path=/socket, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredSSEPathWithoutBroker(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
e, err := api.New(api.WithSSE(nil), api.WithSSEPath("/events"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := e.OpenAPISpecBuilder()
|
|
||||||
data, err := builder.Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := spec["x-sse-path"]; got != "/events" {
|
|
||||||
t.Fatalf("expected x-sse-path=/events, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
|
|
||||||
securityScheme := map[string]any{
|
|
||||||
"type": "oauth2",
|
|
||||||
"flows": map[string]any{
|
|
||||||
"clientCredentials": map[string]any{
|
|
||||||
"tokenUrl": "https://auth.example.com/token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
schemes := map[string]any{
|
|
||||||
"oauth2": securityScheme,
|
|
||||||
}
|
|
||||||
|
|
||||||
e, err := api.New(
|
|
||||||
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
|
|
||||||
api.WithSwaggerSecuritySchemes(schemes),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutate the original input after configuration. The builder snapshot should
|
|
||||||
// remain stable and keep the original token URL.
|
|
||||||
securityScheme["type"] = "mutated"
|
|
||||||
securityScheme["flows"].(map[string]any)["clientCredentials"].(map[string]any)["tokenUrl"] = "https://mutated.example.com/token"
|
|
||||||
|
|
||||||
data, err := e.OpenAPISpecBuilder().Build(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal(data, &spec); err != nil {
|
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
|
|
||||||
oauth2, ok := securitySchemes["oauth2"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected oauth2 security scheme in generated spec")
|
|
||||||
}
|
|
||||||
if oauth2["type"] != "oauth2" {
|
|
||||||
t.Fatalf("expected cloned oauth2.type=oauth2, got %v", oauth2["type"])
|
|
||||||
}
|
|
||||||
flows := oauth2["flows"].(map[string]any)
|
|
||||||
clientCredentials := flows["clientCredentials"].(map[string]any)
|
|
||||||
if clientCredentials["tokenUrl"] != "https://auth.example.com/token" {
|
|
||||||
t.Fatalf("expected original tokenUrl to be preserved, got %v", clientCredentials["tokenUrl"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
154
spec_registry.go
154
spec_registry.go
|
|
@ -1,154 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"iter"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// specRegistry stores RouteGroups that should be included in CLI-generated
|
|
||||||
// OpenAPI documents. Packages can register their groups during init and the
|
|
||||||
// API CLI will pick them up when building specs or SDKs.
|
|
||||||
var specRegistry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
groups []RouteGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterSpecGroups adds route groups to the package-level spec registry.
|
|
||||||
// Nil groups are ignored. Registered groups are returned by RegisteredSpecGroups
|
|
||||||
// in the order they were added.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.RegisterSpecGroups(api.NewToolBridge("/mcp"))
|
|
||||||
func RegisterSpecGroups(groups ...RouteGroup) {
|
|
||||||
RegisterSpecGroupsIter(slices.Values(groups))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterSpecGroupsIter adds route groups from an iterator to the package-level
|
|
||||||
// spec registry.
|
|
||||||
//
|
|
||||||
// Nil groups are ignored. Registered groups are returned by RegisteredSpecGroups
|
|
||||||
// in the order they were added.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.RegisterSpecGroupsIter(api.RegisteredSpecGroupsIter())
|
|
||||||
func RegisterSpecGroupsIter(groups iter.Seq[RouteGroup]) {
|
|
||||||
if groups == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
specRegistry.mu.Lock()
|
|
||||||
defer specRegistry.mu.Unlock()
|
|
||||||
|
|
||||||
for group := range groups {
|
|
||||||
if group == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if specRegistryContains(group) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
specRegistry.groups = append(specRegistry.groups, group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredSpecGroups returns a copy of the route groups registered for
|
|
||||||
// CLI-generated OpenAPI documents.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// groups := api.RegisteredSpecGroups()
|
|
||||||
func RegisteredSpecGroups() []RouteGroup {
|
|
||||||
specRegistry.mu.RLock()
|
|
||||||
defer specRegistry.mu.RUnlock()
|
|
||||||
|
|
||||||
out := make([]RouteGroup, len(specRegistry.groups))
|
|
||||||
copy(out, specRegistry.groups)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredSpecGroupsIter returns an iterator over the route groups registered
|
|
||||||
// for CLI-generated OpenAPI documents.
|
|
||||||
//
|
|
||||||
// The iterator snapshots the current registry contents so callers can range
|
|
||||||
// over it without holding the registry lock.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for g := range api.RegisteredSpecGroupsIter() {
|
|
||||||
// _ = g
|
|
||||||
// }
|
|
||||||
func RegisteredSpecGroupsIter() iter.Seq[RouteGroup] {
|
|
||||||
specRegistry.mu.RLock()
|
|
||||||
groups := slices.Clone(specRegistry.groups)
|
|
||||||
specRegistry.mu.RUnlock()
|
|
||||||
|
|
||||||
return slices.Values(groups)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpecGroupsIter returns the registered spec groups plus one optional extra
|
|
||||||
// group, deduplicated by group identity.
|
|
||||||
//
|
|
||||||
// The iterator snapshots the registry before yielding so callers can range
|
|
||||||
// over it without holding the registry lock.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// for g := range api.SpecGroupsIter(api.NewToolBridge("/tools")) {
|
|
||||||
// _ = g
|
|
||||||
// }
|
|
||||||
func SpecGroupsIter(extra RouteGroup) iter.Seq[RouteGroup] {
|
|
||||||
return func(yield func(RouteGroup) bool) {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for group := range RegisteredSpecGroupsIter() {
|
|
||||||
key := specGroupKey(group)
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
if !yield(group) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extra != nil {
|
|
||||||
if _, ok := seen[specGroupKey(extra)]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !yield(extra) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetSpecGroups clears the package-level spec registry.
|
|
||||||
// It is primarily intended for tests that need to isolate global state.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// api.ResetSpecGroups()
|
|
||||||
func ResetSpecGroups() {
|
|
||||||
specRegistry.mu.Lock()
|
|
||||||
defer specRegistry.mu.Unlock()
|
|
||||||
|
|
||||||
specRegistry.groups = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func specRegistryContains(group RouteGroup) bool {
|
|
||||||
key := specGroupKey(group)
|
|
||||||
for _, existing := range specRegistry.groups {
|
|
||||||
if specGroupKey(existing) == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func specGroupKey(group RouteGroup) string {
|
|
||||||
if group == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return group.Name() + "\x00" + group.BasePath()
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"iter"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
api "dappco.re/go/core/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type specRegistryStubGroup struct {
|
|
||||||
name string
|
|
||||||
basePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *specRegistryStubGroup) Name() string { return g.name }
|
|
||||||
func (g *specRegistryStubGroup) BasePath() string { return g.basePath }
|
|
||||||
func (g *specRegistryStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
|
|
||||||
|
|
||||||
func TestRegisterSpecGroups_Good_DeduplicatesByIdentity(t *testing.T) {
|
|
||||||
snapshot := api.RegisteredSpecGroups()
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
api.RegisterSpecGroups(snapshot...)
|
|
||||||
})
|
|
||||||
|
|
||||||
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
second := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
third := &specRegistryStubGroup{name: "beta", basePath: "/beta"}
|
|
||||||
|
|
||||||
api.RegisterSpecGroups(nil, first, second, third, first)
|
|
||||||
|
|
||||||
groups := api.RegisteredSpecGroups()
|
|
||||||
if len(groups) != 2 {
|
|
||||||
t.Fatalf("expected 2 unique groups, got %d", len(groups))
|
|
||||||
}
|
|
||||||
|
|
||||||
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
|
|
||||||
t.Fatalf("expected first group to be alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
|
|
||||||
}
|
|
||||||
if groups[1].Name() != "beta" || groups[1].BasePath() != "/beta" {
|
|
||||||
t.Fatalf("expected second group to be beta at /beta, got %s at %s", groups[1].Name(), groups[1].BasePath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterSpecGroups_Good_IteratorReturnsSnapshot(t *testing.T) {
|
|
||||||
snapshot := api.RegisteredSpecGroups()
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
api.RegisterSpecGroups(snapshot...)
|
|
||||||
})
|
|
||||||
|
|
||||||
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
second := &specRegistryStubGroup{name: "beta", basePath: "/beta"}
|
|
||||||
|
|
||||||
api.RegisterSpecGroups(first)
|
|
||||||
|
|
||||||
iter := api.RegisteredSpecGroupsIter()
|
|
||||||
|
|
||||||
api.RegisterSpecGroups(second)
|
|
||||||
|
|
||||||
var groups []api.RouteGroup
|
|
||||||
for group := range iter {
|
|
||||||
groups = append(groups, group)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(groups) != 1 {
|
|
||||||
t.Fatalf("expected iterator snapshot to contain 1 group, got %d", len(groups))
|
|
||||||
}
|
|
||||||
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
|
|
||||||
t.Fatalf("expected iterator snapshot to preserve alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterSpecGroupsIter_Good_DeduplicatesAndRegisters(t *testing.T) {
|
|
||||||
snapshot := api.RegisteredSpecGroups()
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
api.RegisterSpecGroups(snapshot...)
|
|
||||||
})
|
|
||||||
|
|
||||||
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
second := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
third := &specRegistryStubGroup{name: "gamma", basePath: "/gamma"}
|
|
||||||
|
|
||||||
groups := iter.Seq[api.RouteGroup](func(yield func(api.RouteGroup) bool) {
|
|
||||||
for _, group := range []api.RouteGroup{first, second, nil, third, first} {
|
|
||||||
if !yield(group) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
api.RegisterSpecGroupsIter(groups)
|
|
||||||
|
|
||||||
registered := api.RegisteredSpecGroups()
|
|
||||||
if len(registered) != 2 {
|
|
||||||
t.Fatalf("expected 2 unique groups, got %d", len(registered))
|
|
||||||
}
|
|
||||||
if registered[0].Name() != "alpha" || registered[0].BasePath() != "/alpha" {
|
|
||||||
t.Fatalf("expected first group to be alpha at /alpha, got %s at %s", registered[0].Name(), registered[0].BasePath())
|
|
||||||
}
|
|
||||||
if registered[1].Name() != "gamma" || registered[1].BasePath() != "/gamma" {
|
|
||||||
t.Fatalf("expected second group to be gamma at /gamma, got %s at %s", registered[1].Name(), registered[1].BasePath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) {
|
|
||||||
snapshot := api.RegisteredSpecGroups()
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
api.ResetSpecGroups()
|
|
||||||
api.RegisterSpecGroups(snapshot...)
|
|
||||||
})
|
|
||||||
|
|
||||||
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
extra := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
|
|
||||||
|
|
||||||
api.RegisterSpecGroups(first)
|
|
||||||
|
|
||||||
var groups []api.RouteGroup
|
|
||||||
for group := range api.SpecGroupsIter(extra) {
|
|
||||||
groups = append(groups, group)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(groups) != 1 {
|
|
||||||
t.Fatalf("expected deduplicated iterator to return 1 group, got %d", len(groups))
|
|
||||||
}
|
|
||||||
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
|
|
||||||
t.Fatalf("expected alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Controllers\Api;
|
|
||||||
|
|
||||||
use Core\Api\Concerns\HasApiResponses;
|
|
||||||
use Core\Api\Concerns\ResolvesWorkspace;
|
|
||||||
use Core\Api\Models\ApiKey;
|
|
||||||
use Core\Api\Services\ApiUsageService;
|
|
||||||
use Core\Front\Controller;
|
|
||||||
use Core\Tenant\Models\Workspace;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entitlements API controller.
|
|
||||||
*
|
|
||||||
* Returns the current workspace's plan limits and usage snapshot.
|
|
||||||
*/
|
|
||||||
class EntitlementApiController extends Controller
|
|
||||||
{
|
|
||||||
use HasApiResponses;
|
|
||||||
use ResolvesWorkspace;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected ApiUsageService $usageService
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the current workspace entitlements.
|
|
||||||
*
|
|
||||||
* GET /api/entitlements
|
|
||||||
*/
|
|
||||||
public function show(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$workspace = $this->resolveWorkspace($request);
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return $this->noWorkspaceResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
$apiKey = $request->attributes->get('api_key');
|
|
||||||
$authType = $request->attributes->get('auth_type', 'session');
|
|
||||||
$rateLimitProfile = $this->resolveRateLimitProfile($authType);
|
|
||||||
$activeApiKeys = ApiKey::query()
|
|
||||||
->forWorkspace($workspace->id)
|
|
||||||
->active()
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$usage = $this->usageService->getWorkspaceSummary($workspace->id);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'workspace_id' => $workspace->id,
|
|
||||||
'workspace' => [
|
|
||||||
'id' => $workspace->id,
|
|
||||||
'name' => $workspace->name ?? null,
|
|
||||||
],
|
|
||||||
'authentication' => [
|
|
||||||
'type' => $authType,
|
|
||||||
'scopes' => $apiKey instanceof ApiKey ? $apiKey->scopes : null,
|
|
||||||
],
|
|
||||||
'limits' => [
|
|
||||||
'rate_limit' => $rateLimitProfile,
|
|
||||||
'api_keys' => [
|
|
||||||
'active' => $activeApiKeys,
|
|
||||||
'maximum' => (int) config('api.keys.max_per_workspace', 10),
|
|
||||||
'remaining' => max(0, (int) config('api.keys.max_per_workspace', 10) - $activeApiKeys),
|
|
||||||
],
|
|
||||||
'webhooks' => [
|
|
||||||
'maximum' => (int) config('api.webhooks.max_per_workspace', 5),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'usage' => $usage,
|
|
||||||
'features' => [
|
|
||||||
'pixel' => true,
|
|
||||||
'mcp' => true,
|
|
||||||
'webhooks' => true,
|
|
||||||
'usage_alerts' => (bool) config('api.alerts.enabled', true),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the rate limit profile for the current auth context.
|
|
||||||
*/
|
|
||||||
protected function resolveRateLimitProfile(string $authType): array
|
|
||||||
{
|
|
||||||
$rateLimits = (array) config('api.rate_limits', []);
|
|
||||||
$key = $authType === 'session' ? 'default' : 'authenticated';
|
|
||||||
$profile = (array) ($rateLimits[$key] ?? []);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => $key,
|
|
||||||
'limit' => (int) ($profile['limit'] ?? 0),
|
|
||||||
'window' => (int) ($profile['window'] ?? 60),
|
|
||||||
'burst' => (float) ($profile['burst'] ?? 1.0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Controllers\Api;
|
|
||||||
|
|
||||||
use Core\Api\Concerns\HasApiResponses;
|
|
||||||
use Core\Api\Documentation\Attributes\ApiParameter;
|
|
||||||
use Core\Api\Documentation\Attributes\ApiTag;
|
|
||||||
use Core\Api\Services\SeoReportService;
|
|
||||||
use Core\Front\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SEO report and analysis controller.
|
|
||||||
*/
|
|
||||||
#[ApiTag('SEO', 'SEO report and analysis endpoints')]
|
|
||||||
class SeoReportController extends Controller
|
|
||||||
{
|
|
||||||
use HasApiResponses;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected SeoReportService $seoReportService
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyse a URL and return a technical SEO report.
|
|
||||||
*
|
|
||||||
* GET /api/seo/report?url=https://example.com
|
|
||||||
*/
|
|
||||||
#[ApiParameter(
|
|
||||||
name: 'url',
|
|
||||||
in: 'query',
|
|
||||||
type: 'string',
|
|
||||||
description: 'URL to analyse',
|
|
||||||
required: true,
|
|
||||||
format: 'uri'
|
|
||||||
)]
|
|
||||||
public function show(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'url' => ['required', 'url'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$report = $this->seoReportService->analyse($validated['url']);
|
|
||||||
} catch (RuntimeException) {
|
|
||||||
return $this->errorResponse(
|
|
||||||
errorCode: 'seo_unavailable',
|
|
||||||
message: 'Unable to fetch the requested URL.',
|
|
||||||
meta: [
|
|
||||||
'provider' => 'seo',
|
|
||||||
],
|
|
||||||
status: 502,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $report,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Controllers\Api;
|
|
||||||
|
|
||||||
use Core\Api\Documentation\Attributes\ApiResponse;
|
|
||||||
use Core\Api\Documentation\Attributes\ApiTag;
|
|
||||||
use Core\Api\RateLimit\RateLimit;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified tracking pixel controller.
|
|
||||||
*
|
|
||||||
* GET /api/pixel/{pixelKey} returns a transparent 1x1 GIF for image embeds.
|
|
||||||
* POST /api/pixel/{pixelKey} returns 204 No Content for fetch-based tracking.
|
|
||||||
*/
|
|
||||||
#[ApiTag('Pixel', 'Unified tracking pixel endpoint')]
|
|
||||||
class UnifiedPixelController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Transparent 1x1 GIF used by browser pixel embeds.
|
|
||||||
*/
|
|
||||||
private const TRANSPARENT_GIF = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track a pixel hit.
|
|
||||||
*
|
|
||||||
* GET /api/pixel/abc12345 -> transparent GIF
|
|
||||||
* POST /api/pixel/abc12345 -> 204 No Content
|
|
||||||
*/
|
|
||||||
#[ApiResponse(
|
|
||||||
200,
|
|
||||||
null,
|
|
||||||
'Transparent 1x1 GIF pixel response',
|
|
||||||
contentType: 'image/gif',
|
|
||||||
schema: [
|
|
||||||
'type' => 'string',
|
|
||||||
'format' => 'binary',
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[ApiResponse(204, null, 'Accepted without a response body')]
|
|
||||||
#[RateLimit(limit: 10000, window: 60)]
|
|
||||||
public function track(Request $request, string $pixelKey): Response
|
|
||||||
{
|
|
||||||
if ($request->isMethod('post')) {
|
|
||||||
return response()->noContent()
|
|
||||||
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
|
||||||
->header('Pragma', 'no-cache')
|
|
||||||
->header('Expires', '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pixel = base64_decode(self::TRANSPARENT_GIF);
|
|
||||||
|
|
||||||
return response($pixel, 200)
|
|
||||||
->header('Content-Type', 'image/gif')
|
|
||||||
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
|
||||||
->header('Pragma', 'no-cache')
|
|
||||||
->header('Expires', '0')
|
|
||||||
->header('Content-Length', (string) strlen($pixel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@ namespace Core\Api\Controllers;
|
||||||
|
|
||||||
use Core\Front\Controller;
|
use Core\Front\Controller;
|
||||||
use Core\Api\Concerns\HasApiResponses;
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Core\Api\Documentation\Attributes\ApiParameter;
|
|
||||||
use Core\Api\Models\ApiKey;
|
use Core\Api\Models\ApiKey;
|
||||||
use Core\Mod\Mcp\Models\McpApiRequest;
|
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||||
use Core\Mod\Mcp\Models\McpToolCall;
|
use Core\Mod\Mcp\Models\McpToolCall;
|
||||||
|
|
@ -51,29 +50,7 @@ class McpApiController extends Controller
|
||||||
* Get server details with tools and resources.
|
* Get server details with tools and resources.
|
||||||
*
|
*
|
||||||
* GET /api/v1/mcp/servers/{id}
|
* GET /api/v1/mcp/servers/{id}
|
||||||
*
|
|
||||||
* Query params:
|
|
||||||
* - include_versions: bool - include version info for each tool
|
|
||||||
* - include_content: bool - include resource content when the definition already contains it
|
|
||||||
*/
|
*/
|
||||||
#[ApiParameter(
|
|
||||||
name: 'include_versions',
|
|
||||||
in: 'query',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Include version information for each tool',
|
|
||||||
required: false,
|
|
||||||
example: false,
|
|
||||||
default: false
|
|
||||||
)]
|
|
||||||
#[ApiParameter(
|
|
||||||
name: 'include_content',
|
|
||||||
in: 'query',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Include resource content when the definition already contains it',
|
|
||||||
required: false,
|
|
||||||
example: false,
|
|
||||||
default: false
|
|
||||||
)]
|
|
||||||
public function server(Request $request, string $id): JsonResponse
|
public function server(Request $request, string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$server = $this->loadServerFull($id);
|
$server = $this->loadServerFull($id);
|
||||||
|
|
@ -82,17 +59,6 @@ class McpApiController extends Controller
|
||||||
return $this->notFoundResponse('Server');
|
return $this->notFoundResponse('Server');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->boolean('include_versions', false)) {
|
|
||||||
$server['tools'] = $this->enrichToolsWithVersioning($id, $server['tools'] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->boolean('include_content', false)) {
|
|
||||||
$server['resources'] = $this->enrichResourcesWithContent($server['resources'] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
$server['tool_count'] = count($server['tools'] ?? []);
|
|
||||||
$server['resource_count'] = count($server['resources'] ?? []);
|
|
||||||
|
|
||||||
return response()->json($server);
|
return response()->json($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,15 +70,6 @@ class McpApiController extends Controller
|
||||||
* Query params:
|
* Query params:
|
||||||
* - include_versions: bool - include version info for each tool
|
* - include_versions: bool - include version info for each tool
|
||||||
*/
|
*/
|
||||||
#[ApiParameter(
|
|
||||||
name: 'include_versions',
|
|
||||||
in: 'query',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Include version information for each tool',
|
|
||||||
required: false,
|
|
||||||
example: false,
|
|
||||||
default: false
|
|
||||||
)]
|
|
||||||
public function tools(Request $request, string $id): JsonResponse
|
public function tools(Request $request, string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$server = $this->loadServerFull($id);
|
$server = $this->loadServerFull($id);
|
||||||
|
|
@ -153,116 +110,6 @@ class McpApiController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List resources for a specific server.
|
|
||||||
*
|
|
||||||
* GET /api/v1/mcp/servers/{id}/resources
|
|
||||||
*
|
|
||||||
* Query params:
|
|
||||||
* - include_content: bool - include resource content when the definition already contains it
|
|
||||||
*/
|
|
||||||
#[ApiParameter(
|
|
||||||
name: 'include_content',
|
|
||||||
in: 'query',
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'Include resource content when the definition already contains it',
|
|
||||||
required: false,
|
|
||||||
example: false,
|
|
||||||
default: false
|
|
||||||
)]
|
|
||||||
public function resources(Request $request, string $id): JsonResponse
|
|
||||||
{
|
|
||||||
$server = $this->loadServerFull($id);
|
|
||||||
|
|
||||||
if (! $server) {
|
|
||||||
return $this->notFoundResponse('Server');
|
|
||||||
}
|
|
||||||
|
|
||||||
$includeContent = $request->boolean('include_content', false);
|
|
||||||
|
|
||||||
$resources = collect($server['resources'] ?? [])
|
|
||||||
->filter(fn ($resource) => is_array($resource))
|
|
||||||
->map(function (array $resource) use ($includeContent) {
|
|
||||||
$payload = array_filter([
|
|
||||||
'uri' => $resource['uri'] ?? null,
|
|
||||||
'path' => $resource['path'] ?? null,
|
|
||||||
'name' => $resource['name'] ?? null,
|
|
||||||
'description' => $resource['description'] ?? null,
|
|
||||||
'mime_type' => $resource['mime_type'] ?? ($resource['mimeType'] ?? null),
|
|
||||||
], static fn ($value) => $value !== null);
|
|
||||||
|
|
||||||
if ($includeContent && $this->resourceDefinitionHasContent($resource)) {
|
|
||||||
$payload['content'] = $this->normaliseResourceContent($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'server' => $id,
|
|
||||||
'resources' => $resources,
|
|
||||||
'count' => $resources->count(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enrich a tool collection with version metadata.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $tools
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
protected function enrichToolsWithVersioning(string $serverId, array $tools): array
|
|
||||||
{
|
|
||||||
$versionService = app(ToolVersionService::class);
|
|
||||||
|
|
||||||
return collect($tools)->map(function (array $tool) use ($serverId, $versionService) {
|
|
||||||
$toolName = $tool['name'] ?? '';
|
|
||||||
$latestVersion = $versionService->getLatestVersion($serverId, $toolName);
|
|
||||||
|
|
||||||
$tool['versioning'] = [
|
|
||||||
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
|
||||||
'is_versioned' => $latestVersion !== null,
|
|
||||||
'deprecated' => $latestVersion?->is_deprecated ?? false,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($latestVersion?->input_schema) {
|
|
||||||
$tool['inputSchema'] = $latestVersion->input_schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tool;
|
|
||||||
})->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enrich a resource collection with inline content when available.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $resources
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
protected function enrichResourcesWithContent(array $resources): array
|
|
||||||
{
|
|
||||||
return collect($resources)
|
|
||||||
->filter(fn ($resource) => is_array($resource))
|
|
||||||
->map(function (array $resource) {
|
|
||||||
$payload = array_filter([
|
|
||||||
'uri' => $resource['uri'] ?? null,
|
|
||||||
'path' => $resource['path'] ?? null,
|
|
||||||
'name' => $resource['name'] ?? null,
|
|
||||||
'description' => $resource['description'] ?? null,
|
|
||||||
'mime_type' => $resource['mime_type'] ?? ($resource['mimeType'] ?? null),
|
|
||||||
], static fn ($value) => $value !== null);
|
|
||||||
|
|
||||||
if ($this->resourceDefinitionHasContent($resource)) {
|
|
||||||
$payload['content'] = $this->normaliseResourceContent($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a tool on an MCP server.
|
* Execute a tool on an MCP server.
|
||||||
*
|
*
|
||||||
|
|
@ -361,8 +208,7 @@ class McpApiController extends Controller
|
||||||
$result = $this->executeToolViaArtisan(
|
$result = $this->executeToolViaArtisan(
|
||||||
$validated['server'],
|
$validated['server'],
|
||||||
$validated['tool'],
|
$validated['tool'],
|
||||||
$validated['arguments'] ?? [],
|
$validated['arguments'] ?? []
|
||||||
$toolVersion?->version
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
@ -567,8 +413,6 @@ class McpApiController extends Controller
|
||||||
*/
|
*/
|
||||||
public function resource(Request $request, string $uri): JsonResponse
|
public function resource(Request $request, string $uri): JsonResponse
|
||||||
{
|
{
|
||||||
$uri = rawurldecode($uri);
|
|
||||||
|
|
||||||
// Parse URI format: server://resource/path
|
// Parse URI format: server://resource/path
|
||||||
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||||
return $this->validationErrorResponse([
|
return $this->validationErrorResponse([
|
||||||
|
|
@ -584,35 +428,12 @@ class McpApiController extends Controller
|
||||||
return $this->notFoundResponse('Server');
|
return $this->notFoundResponse('Server');
|
||||||
}
|
}
|
||||||
|
|
||||||
$resourceDef = $this->findResourceDefinition($server, $uri, $resourcePath);
|
|
||||||
if ($resourceDef !== null && $this->resourceDefinitionHasContent($resourceDef)) {
|
|
||||||
return response()->json([
|
|
||||||
'uri' => $uri,
|
|
||||||
'server' => $serverId,
|
|
||||||
'resource' => $resourcePath,
|
|
||||||
'content' => $this->normaliseResourceContent($resourceDef),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
||||||
if ($result === null) {
|
|
||||||
return $this->notFoundResponse('Resource');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($result) && array_key_exists('content', $result)) {
|
|
||||||
$content = $result['content'];
|
|
||||||
} elseif (is_array($result) && array_key_exists('contents', $result)) {
|
|
||||||
$content = $result['contents'];
|
|
||||||
} else {
|
|
||||||
$content = $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'uri' => $uri,
|
'uri' => $uri,
|
||||||
'server' => $serverId,
|
'content' => $result,
|
||||||
'resource' => $resourcePath,
|
|
||||||
'content' => $content,
|
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return $this->errorResponse(
|
return $this->errorResponse(
|
||||||
|
|
@ -629,14 +450,32 @@ class McpApiController extends Controller
|
||||||
/**
|
/**
|
||||||
* Execute tool via artisan MCP server command.
|
* Execute tool via artisan MCP server command.
|
||||||
*/
|
*/
|
||||||
protected function executeToolViaArtisan(string $server, string $tool, array $arguments, ?string $version = null): mixed
|
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
|
||||||
{
|
{
|
||||||
$command = $this->resolveMcpServerCommand($server);
|
$commandMap = [
|
||||||
|
'hosthub-agent' => 'mcp:agent-server',
|
||||||
|
'socialhost' => 'mcp:socialhost-server',
|
||||||
|
'biohost' => 'mcp:biohost-server',
|
||||||
|
'commerce' => 'mcp:commerce-server',
|
||||||
|
'supporthost' => 'mcp:support-server',
|
||||||
|
'upstream' => 'mcp:upstream-server',
|
||||||
|
];
|
||||||
|
|
||||||
|
$command = $commandMap[$server] ?? null;
|
||||||
if (! $command) {
|
if (! $command) {
|
||||||
throw new \RuntimeException("Unknown server: {$server}");
|
throw new \RuntimeException("Unknown server: {$server}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$mcpRequest = $this->buildToolCallRequest($tool, $arguments, $version);
|
// Build MCP request
|
||||||
|
$mcpRequest = [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => uniqid(),
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => $tool,
|
||||||
|
'arguments' => $arguments,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
// Execute via process
|
// Execute via process
|
||||||
$process = proc_open(
|
$process = proc_open(
|
||||||
|
|
@ -672,157 +511,14 @@ class McpApiController extends Controller
|
||||||
return $response['result'] ?? null;
|
return $response['result'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the JSON-RPC payload for an MCP tool call.
|
|
||||||
*/
|
|
||||||
protected function buildToolCallRequest(string $tool, array $arguments, ?string $version = null): array
|
|
||||||
{
|
|
||||||
$params = [
|
|
||||||
'name' => $tool,
|
|
||||||
'arguments' => $arguments,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($version !== null && $version !== '') {
|
|
||||||
$params['version'] = $version;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'jsonrpc' => '2.0',
|
|
||||||
'id' => uniqid(),
|
|
||||||
'method' => 'tools/call',
|
|
||||||
'params' => $params,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read resource via artisan MCP server command.
|
* Read resource via artisan MCP server command.
|
||||||
*/
|
*/
|
||||||
protected function readResourceViaArtisan(string $server, string $path): mixed
|
protected function readResourceViaArtisan(string $server, string $path): mixed
|
||||||
{
|
{
|
||||||
$command = $this->resolveMcpServerCommand($server);
|
// Similar to executeToolViaArtisan but with resources/read method
|
||||||
if (! $command) {
|
// Simplified for now - can expand later
|
||||||
throw new \RuntimeException("Unknown server: {$server}");
|
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
||||||
}
|
|
||||||
|
|
||||||
$mcpRequest = [
|
|
||||||
'jsonrpc' => '2.0',
|
|
||||||
'id' => uniqid(),
|
|
||||||
'method' => 'resources/read',
|
|
||||||
'params' => [
|
|
||||||
'uri' => "{$server}://{$path}",
|
|
||||||
'path' => $path,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$process = proc_open(
|
|
||||||
['php', 'artisan', $command],
|
|
||||||
[
|
|
||||||
0 => ['pipe', 'r'],
|
|
||||||
1 => ['pipe', 'w'],
|
|
||||||
2 => ['pipe', 'w'],
|
|
||||||
],
|
|
||||||
$pipes,
|
|
||||||
base_path()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! is_resource($process)) {
|
|
||||||
throw new \RuntimeException('Failed to start MCP server process');
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite($pipes[0], json_encode($mcpRequest)."\n");
|
|
||||||
fclose($pipes[0]);
|
|
||||||
|
|
||||||
$output = stream_get_contents($pipes[1]);
|
|
||||||
fclose($pipes[1]);
|
|
||||||
fclose($pipes[2]);
|
|
||||||
|
|
||||||
proc_close($process);
|
|
||||||
|
|
||||||
$response = json_decode($output, true);
|
|
||||||
if (! is_array($response)) {
|
|
||||||
throw new \RuntimeException('Invalid MCP resource response');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($response['error'])) {
|
|
||||||
throw new \RuntimeException($response['error']['message'] ?? 'Resource read failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response['result'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the artisan command used for a given MCP server.
|
|
||||||
*/
|
|
||||||
protected function resolveMcpServerCommand(string $server): ?string
|
|
||||||
{
|
|
||||||
$commandMap = [
|
|
||||||
'hosthub-agent' => 'mcp:agent-server',
|
|
||||||
'socialhost' => 'mcp:socialhost-server',
|
|
||||||
'biohost' => 'mcp:biohost-server',
|
|
||||||
'commerce' => 'mcp:commerce-server',
|
|
||||||
'supporthost' => 'mcp:support-server',
|
|
||||||
'upstream' => 'mcp:upstream-server',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $commandMap[$server] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a resource definition within the loaded server config.
|
|
||||||
*/
|
|
||||||
protected function findResourceDefinition(array $server, string $uri, string $path): mixed
|
|
||||||
{
|
|
||||||
foreach ($server['resources'] ?? [] as $resource) {
|
|
||||||
if (! is_array($resource)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resourceUri = $resource['uri'] ?? null;
|
|
||||||
$resourcePath = $resource['path'] ?? null;
|
|
||||||
$resourceName = $resource['name'] ?? null;
|
|
||||||
|
|
||||||
if ($resourceUri === $uri || $resourcePath === $path || $resourceName === basename($path)) {
|
|
||||||
return $resource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise a resource definition into a response payload.
|
|
||||||
*/
|
|
||||||
protected function normaliseResourceContent(mixed $resource): mixed
|
|
||||||
{
|
|
||||||
if (! is_array($resource)) {
|
|
||||||
return $resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
|
|
||||||
if (array_key_exists($field, $resource)) {
|
|
||||||
return $resource[$field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether a resource definition already carries readable content.
|
|
||||||
*/
|
|
||||||
protected function resourceDefinitionHasContent(mixed $resource): bool
|
|
||||||
{
|
|
||||||
if (! is_array($resource)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
|
|
||||||
if (array_key_exists($field, $resource)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,6 @@ use Attribute;
|
||||||
* {
|
* {
|
||||||
* return UserResource::collection(User::paginate());
|
* return UserResource::collection(User::paginate());
|
||||||
* }
|
* }
|
||||||
*
|
|
||||||
* // For non-JSON or binary responses
|
|
||||||
* #[ApiResponse(
|
|
||||||
* 200,
|
|
||||||
* null,
|
|
||||||
* 'Transparent tracking pixel',
|
|
||||||
* contentType: 'image/gif',
|
|
||||||
* schema: ['type' => 'string', 'format' => 'binary']
|
|
||||||
* )]
|
|
||||||
* public function pixel()
|
|
||||||
* {
|
|
||||||
* return response($gif, 200)->header('Content-Type', 'image/gif');
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||||
readonly class ApiResponse
|
readonly class ApiResponse
|
||||||
|
|
@ -50,8 +37,6 @@ readonly class ApiResponse
|
||||||
* @param string|null $description Description of the response
|
* @param string|null $description Description of the response
|
||||||
* @param bool $paginated Whether this is a paginated collection response
|
* @param bool $paginated Whether this is a paginated collection response
|
||||||
* @param array<string> $headers Additional response headers to document
|
* @param array<string> $headers Additional response headers to document
|
||||||
* @param string|null $contentType Explicit response media type for non-JSON responses
|
|
||||||
* @param array<string, mixed>|null $schema Explicit response schema when the body is not inferred from a resource
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $status,
|
public int $status,
|
||||||
|
|
@ -59,8 +44,6 @@ readonly class ApiResponse
|
||||||
public ?string $description = null,
|
public ?string $description = null,
|
||||||
public bool $paginated = false,
|
public bool $paginated = false,
|
||||||
public array $headers = [],
|
public array $headers = [],
|
||||||
public ?string $contentType = null,
|
|
||||||
public ?array $schema = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,11 +64,10 @@ readonly class ApiResponse
|
||||||
302 => 'Found (redirect)',
|
302 => 'Found (redirect)',
|
||||||
304 => 'Not modified',
|
304 => 'Not modified',
|
||||||
400 => 'Bad request',
|
400 => 'Bad request',
|
||||||
401 => 'Unauthorised',
|
401 => 'Unauthorized',
|
||||||
403 => 'Forbidden',
|
403 => 'Forbidden',
|
||||||
404 => 'Not found',
|
404 => 'Not found',
|
||||||
405 => 'Method not allowed',
|
405 => 'Method not allowed',
|
||||||
410 => 'Gone',
|
|
||||||
409 => 'Conflict',
|
409 => 'Conflict',
|
||||||
422 => 'Validation error',
|
422 => 'Validation error',
|
||||||
429 => 'Too many requests',
|
429 => 'Too many requests',
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ class DocumentationController
|
||||||
return match ($defaultUi) {
|
return match ($defaultUi) {
|
||||||
'swagger' => $this->swagger($request),
|
'swagger' => $this->swagger($request),
|
||||||
'redoc' => $this->redoc($request),
|
'redoc' => $this->redoc($request),
|
||||||
'stoplight' => $this->stoplight($request),
|
|
||||||
default => $this->scalar($request),
|
default => $this->scalar($request),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -75,19 +74,6 @@ class DocumentationController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show Stoplight Elements.
|
|
||||||
*/
|
|
||||||
public function stoplight(Request $request): View
|
|
||||||
{
|
|
||||||
$config = config('api-docs.ui.stoplight', []);
|
|
||||||
|
|
||||||
return view('api-docs::stoplight', [
|
|
||||||
'specUrl' => route('api.docs.openapi.json'),
|
|
||||||
'config' => $config,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OpenAPI specification as JSON.
|
* Get OpenAPI specification as JSON.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class ApiKeyAuthExtension implements Extension
|
||||||
'properties' => [
|
'properties' => [
|
||||||
'message' => [
|
'message' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'example' => 'This action is unauthorised.',
|
'example' => 'This action is unauthorized.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Documentation\Extensions;
|
|
||||||
|
|
||||||
use Core\Api\Documentation\Extension;
|
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sunset Extension.
|
|
||||||
*
|
|
||||||
* Documents endpoint deprecation and sunset metadata for routes using
|
|
||||||
* the `api.sunset` middleware.
|
|
||||||
*/
|
|
||||||
class SunsetExtension implements Extension
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Extend the complete OpenAPI specification.
|
|
||||||
*/
|
|
||||||
public function extend(array $spec, array $config): array
|
|
||||||
{
|
|
||||||
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
|
|
||||||
|
|
||||||
$spec['components']['headers']['deprecation'] = [
|
|
||||||
'description' => 'Indicates that the endpoint is deprecated.',
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'enum' => ['true'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$spec['components']['headers']['sunset'] = [
|
|
||||||
'description' => 'The date and time after which the endpoint will no longer be supported.',
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'format' => 'date-time',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$spec['components']['headers']['link'] = [
|
|
||||||
'description' => 'Reference to the successor endpoint, when one is provided.',
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'string',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$spec['components']['headers']['xapiwarn'] = [
|
|
||||||
'description' => 'Human-readable deprecation warning for clients.',
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'string',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return $spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extend an individual operation.
|
|
||||||
*/
|
|
||||||
public function extendOperation(array $operation, Route $route, string $method, array $config): array
|
|
||||||
{
|
|
||||||
$sunset = $this->sunsetMiddlewareArguments($route);
|
|
||||||
|
|
||||||
if ($sunset === null) {
|
|
||||||
return $operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operation['deprecated'] = true;
|
|
||||||
|
|
||||||
foreach ($operation['responses'] as $status => &$response) {
|
|
||||||
if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 300) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response['headers'] = $response['headers'] ?? [];
|
|
||||||
|
|
||||||
$response['headers']['Deprecation'] = [
|
|
||||||
'$ref' => '#/components/headers/deprecation',
|
|
||||||
];
|
|
||||||
if ($sunset['sunsetDate'] !== null && $sunset['sunsetDate'] !== '') {
|
|
||||||
$response['headers']['Sunset'] = [
|
|
||||||
'$ref' => '#/components/headers/sunset',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$response['headers']['X-API-Warn'] = [
|
|
||||||
'$ref' => '#/components/headers/xapiwarn',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (
|
|
||||||
$sunset['replacement'] !== null
|
|
||||||
&& $sunset['replacement'] !== ''
|
|
||||||
&& ! isset($response['headers']['Link'])
|
|
||||||
) {
|
|
||||||
$response['headers']['Link'] = [
|
|
||||||
'$ref' => '#/components/headers/link',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($response);
|
|
||||||
|
|
||||||
return $operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the configured sunset middleware arguments from a route.
|
|
||||||
*
|
|
||||||
* Returns null when the route does not use the sunset middleware.
|
|
||||||
*
|
|
||||||
* @return array{sunsetDate:?string,replacement:?string}|null
|
|
||||||
*/
|
|
||||||
protected function sunsetMiddlewareArguments(Route $route): ?array
|
|
||||||
{
|
|
||||||
foreach ($route->middleware() as $middleware) {
|
|
||||||
if (! str_starts_with($middleware, 'api.sunset') && ! str_contains($middleware, 'ApiSunset')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments = null;
|
|
||||||
|
|
||||||
if (str_contains($middleware, ':')) {
|
|
||||||
[, $arguments] = explode(':', $middleware, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($arguments === null || $arguments === '') {
|
|
||||||
return [
|
|
||||||
'sunsetDate' => null,
|
|
||||||
'replacement' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode(',', $arguments, 2);
|
|
||||||
$sunsetDate = trim($parts[0] ?? '');
|
|
||||||
$replacement = isset($parts[1]) ? trim($parts[1]) : null;
|
|
||||||
if ($replacement === '') {
|
|
||||||
$replacement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'sunsetDate' => $sunsetDate !== '' ? $sunsetDate : null,
|
|
||||||
'replacement' => $replacement,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Documentation\Extensions;
|
|
||||||
|
|
||||||
use Core\Api\Documentation\Extension;
|
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Version Extension.
|
|
||||||
*
|
|
||||||
* Documents the X-API-Version response header and version-driven deprecation
|
|
||||||
* metadata for routes using the api.version middleware.
|
|
||||||
*/
|
|
||||||
class VersionExtension implements Extension
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Extend the complete OpenAPI specification.
|
|
||||||
*/
|
|
||||||
public function extend(array $spec, array $config): array
|
|
||||||
{
|
|
||||||
if (! (bool) config('api.headers.include_version', true)) {
|
|
||||||
return $spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
|
|
||||||
$spec['components']['headers']['xapiversion'] = [
|
|
||||||
'description' => 'API version used to process the request.',
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'string',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return $spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extend an individual operation.
|
|
||||||
*/
|
|
||||||
public function extendOperation(array $operation, Route $route, string $method, array $config): array
|
|
||||||
{
|
|
||||||
$version = $this->versionMiddlewareVersion($route);
|
|
||||||
if ($version === null) {
|
|
||||||
return $operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$includeVersion = (bool) config('api.headers.include_version', true);
|
|
||||||
$includeDeprecation = (bool) config('api.headers.include_deprecation', true);
|
|
||||||
|
|
||||||
$deprecatedVersions = array_map('intval', config('api.versioning.deprecated', []));
|
|
||||||
$sunsetDates = config('api.versioning.sunset', []);
|
|
||||||
$isDeprecatedVersion = in_array($version, $deprecatedVersions, true);
|
|
||||||
$sunsetDate = $sunsetDates[$version] ?? null;
|
|
||||||
|
|
||||||
if ($isDeprecatedVersion) {
|
|
||||||
$operation['deprecated'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($operation['responses'] as $status => &$response) {
|
|
||||||
if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 600) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response['headers'] = $response['headers'] ?? [];
|
|
||||||
|
|
||||||
if ($includeVersion && ! isset($response['headers']['X-API-Version'])) {
|
|
||||||
$response['headers']['X-API-Version'] = [
|
|
||||||
'$ref' => '#/components/headers/xapiversion',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $includeDeprecation || ! $isDeprecatedVersion) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response['headers']['Deprecation'] = [
|
|
||||||
'$ref' => '#/components/headers/deprecation',
|
|
||||||
];
|
|
||||||
$response['headers']['X-API-Warn'] = [
|
|
||||||
'$ref' => '#/components/headers/xapiwarn',
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($sunsetDate !== null && $sunsetDate !== '') {
|
|
||||||
$response['headers']['Sunset'] = [
|
|
||||||
'$ref' => '#/components/headers/sunset',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($response);
|
|
||||||
|
|
||||||
return $operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the version number from api.version middleware.
|
|
||||||
*/
|
|
||||||
protected function versionMiddlewareVersion(Route $route): ?int
|
|
||||||
{
|
|
||||||
foreach ($route->middleware() as $middleware) {
|
|
||||||
if (! str_starts_with($middleware, 'api.version') && ! str_contains($middleware, 'ApiVersion')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! str_contains($middleware, ':')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[, $arguments] = explode(':', $middleware, 2);
|
|
||||||
$arguments = trim($arguments);
|
|
||||||
if ($arguments === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode(',', $arguments, 2);
|
|
||||||
$version = ltrim(trim($parts[0] ?? ''), 'vV');
|
|
||||||
if ($version === '' || ! is_numeric($version)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $version;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,8 +11,6 @@ use Core\Api\Documentation\Attributes\ApiSecurity;
|
||||||
use Core\Api\Documentation\Attributes\ApiTag;
|
use Core\Api\Documentation\Attributes\ApiTag;
|
||||||
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
||||||
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
||||||
use Core\Api\Documentation\Extensions\SunsetExtension;
|
|
||||||
use Core\Api\Documentation\Extensions\VersionExtension;
|
|
||||||
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Routing\Route;
|
use Illuminate\Routing\Route;
|
||||||
|
|
@ -59,9 +57,7 @@ class OpenApiBuilder
|
||||||
{
|
{
|
||||||
$this->extensions = [
|
$this->extensions = [
|
||||||
new WorkspaceHeaderExtension,
|
new WorkspaceHeaderExtension,
|
||||||
new VersionExtension,
|
|
||||||
new RateLimitExtension,
|
new RateLimitExtension,
|
||||||
new SunsetExtension,
|
|
||||||
new ApiKeyAuthExtension,
|
new ApiKeyAuthExtension,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +229,6 @@ class OpenApiBuilder
|
||||||
protected function buildPaths(array $config): array
|
protected function buildPaths(array $config): array
|
||||||
{
|
{
|
||||||
$paths = [];
|
$paths = [];
|
||||||
$operationIds = [];
|
|
||||||
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
||||||
$excludePatterns = $config['routes']['exclude'] ?? [];
|
$excludePatterns = $config['routes']['exclude'] ?? [];
|
||||||
|
|
||||||
|
|
@ -248,7 +243,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
foreach ($methods as $method) {
|
foreach ($methods as $method) {
|
||||||
$method = strtolower($method);
|
$method = strtolower($method);
|
||||||
$operation = $this->buildOperation($route, $method, $config, $operationIds);
|
$operation = $this->buildOperation($route, $method, $config);
|
||||||
|
|
||||||
if ($operation !== null) {
|
if ($operation !== null) {
|
||||||
$paths[$path][$method] = $operation;
|
$paths[$path][$method] = $operation;
|
||||||
|
|
@ -302,7 +297,7 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build operation for a specific route and method.
|
* Build operation for a specific route and method.
|
||||||
*/
|
*/
|
||||||
protected function buildOperation(Route $route, string $method, array $config, array &$operationIds): ?array
|
protected function buildOperation(Route $route, string $method, array $config): ?array
|
||||||
{
|
{
|
||||||
$controller = $route->getController();
|
$controller = $route->getController();
|
||||||
$action = $route->getActionMethod();
|
$action = $route->getActionMethod();
|
||||||
|
|
@ -314,7 +309,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
$operation = [
|
$operation = [
|
||||||
'summary' => $this->buildSummary($route, $method),
|
'summary' => $this->buildSummary($route, $method),
|
||||||
'operationId' => $this->buildOperationId($route, $method, $operationIds),
|
'operationId' => $this->buildOperationId($route, $method),
|
||||||
'tags' => $this->buildOperationTags($route, $controller, $action),
|
'tags' => $this->buildOperationTags($route, $controller, $action),
|
||||||
'responses' => $this->buildResponses($controller, $action),
|
'responses' => $this->buildResponses($controller, $action),
|
||||||
];
|
];
|
||||||
|
|
@ -333,7 +328,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
// Add request body for POST/PUT/PATCH
|
// Add request body for POST/PUT/PATCH
|
||||||
if (in_array($method, ['post', 'put', 'patch'])) {
|
if (in_array($method, ['post', 'put', 'patch'])) {
|
||||||
$operation['requestBody'] = $this->buildRequestBody($route, $controller, $action);
|
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add security requirements
|
// Add security requirements
|
||||||
|
|
@ -403,24 +398,15 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build operation ID from route name.
|
* Build operation ID from route name.
|
||||||
*/
|
*/
|
||||||
protected function buildOperationId(Route $route, string $method, array &$operationIds): string
|
protected function buildOperationId(Route $route, string $method): string
|
||||||
{
|
{
|
||||||
$name = $route->getName();
|
$name = $route->getName();
|
||||||
|
|
||||||
if ($name) {
|
if ($name) {
|
||||||
$base = Str::camel(str_replace(['.', '-'], '_', $name));
|
return Str::camel(str_replace(['.', '-'], '_', $name));
|
||||||
} else {
|
|
||||||
$base = Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$count = $operationIds[$base] ?? 0;
|
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||||
$operationIds[$base] = $count + 1;
|
|
||||||
|
|
||||||
if ($count === 0) {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $base.'_'.($count + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -525,36 +511,16 @@ class OpenApiBuilder
|
||||||
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
|
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
|
||||||
{
|
{
|
||||||
$parameters = [];
|
$parameters = [];
|
||||||
$parameterIndex = [];
|
|
||||||
|
|
||||||
$addParameter = function (array $parameter) use (&$parameters, &$parameterIndex): void {
|
|
||||||
$name = $parameter['name'] ?? null;
|
|
||||||
$in = $parameter['in'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($name) || $name === '' || ! is_string($in) || $in === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = $in.':'.$name;
|
|
||||||
if (isset($parameterIndex[$key])) {
|
|
||||||
$parameters[$parameterIndex[$key]] = $parameter;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parameterIndex[$key] = count($parameters);
|
|
||||||
$parameters[] = $parameter;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add path parameters
|
// Add path parameters
|
||||||
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
||||||
foreach ($matches[1] as $param) {
|
foreach ($matches[1] as $param) {
|
||||||
$addParameter([
|
$parameters[] = [
|
||||||
'name' => $param,
|
'name' => $param,
|
||||||
'in' => 'path',
|
'in' => 'path',
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'schema' => ['type' => 'string'],
|
'schema' => ['type' => 'string'],
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add parameters from ApiParameter attributes
|
// Add parameters from ApiParameter attributes
|
||||||
|
|
@ -566,12 +532,12 @@ class OpenApiBuilder
|
||||||
|
|
||||||
foreach ($paramAttrs as $attr) {
|
foreach ($paramAttrs as $attr) {
|
||||||
$param = $attr->newInstance();
|
$param = $attr->newInstance();
|
||||||
$addParameter($param->toOpenApi());
|
$parameters[] = $param->toOpenApi();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values($parameters);
|
return $parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -612,23 +578,15 @@ class OpenApiBuilder
|
||||||
'description' => $response->getDescription(),
|
'description' => $response->getDescription(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$schema = null;
|
if ($response->resource !== null && class_exists($response->resource)) {
|
||||||
|
|
||||||
if (is_array($response->schema) && ! empty($response->schema)) {
|
|
||||||
$schema = $response->schema;
|
|
||||||
} elseif ($response->resource !== null && class_exists($response->resource)) {
|
|
||||||
$schema = $this->extractResourceSchema($response->resource);
|
$schema = $this->extractResourceSchema($response->resource);
|
||||||
|
|
||||||
if ($response->paginated) {
|
if ($response->paginated) {
|
||||||
$schema = $this->wrapPaginatedSchema($schema);
|
$schema = $this->wrapPaginatedSchema($schema);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($schema !== null) {
|
|
||||||
$contentType = $response->contentType ?: 'application/json';
|
|
||||||
|
|
||||||
$result['content'] = [
|
$result['content'] = [
|
||||||
$contentType => [
|
'application/json' => [
|
||||||
'schema' => $schema,
|
'schema' => $schema,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -656,181 +614,14 @@ class OpenApiBuilder
|
||||||
return ['type' => 'object'];
|
return ['type' => 'object'];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// For now, return a generic object schema
|
||||||
$resource = new $resourceClass(new \stdClass);
|
// A more sophisticated implementation would analyze the resource's toArray method
|
||||||
$data = $resource->toArray(request());
|
|
||||||
|
|
||||||
if (is_array($data)) {
|
|
||||||
return $this->inferArraySchema($data);
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Fall back to a generic object schema when the resource cannot
|
|
||||||
// be instantiated safely in the current context.
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'additionalProperties' => true,
|
'additionalProperties' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer an OpenAPI schema from a PHP array.
|
|
||||||
*/
|
|
||||||
protected function inferArraySchema(array $value): array
|
|
||||||
{
|
|
||||||
if (array_is_list($value)) {
|
|
||||||
$itemSchema = ['type' => 'object'];
|
|
||||||
|
|
||||||
foreach ($value as $item) {
|
|
||||||
if ($item === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$itemSchema = $this->inferValueSchema($item);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'type' => 'array',
|
|
||||||
'items' => $itemSchema,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$properties = [];
|
|
||||||
foreach ($value as $key => $item) {
|
|
||||||
$properties[(string) $key] = $this->inferValueSchema($item, (string) $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => $properties,
|
|
||||||
'additionalProperties' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer an OpenAPI schema node from a PHP value.
|
|
||||||
*/
|
|
||||||
protected function inferValueSchema(mixed $value, ?string $key = null): array
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return $this->inferNullableSchema($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_bool($value)) {
|
|
||||||
return ['type' => 'boolean'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_int($value)) {
|
|
||||||
return ['type' => 'integer'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_float($value)) {
|
|
||||||
return ['type' => 'number'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
return $this->inferStringSchema($value, $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
return $this->inferArraySchema($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_object($value)) {
|
|
||||||
return $this->inferObjectSchema($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer a schema for a null value using the field name as a hint.
|
|
||||||
*/
|
|
||||||
protected function inferNullableSchema(?string $key): array
|
|
||||||
{
|
|
||||||
if ($key === null) {
|
|
||||||
return ['nullable' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = strtolower($key);
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$normalized === 'id',
|
|
||||||
str_ends_with($normalized, '_id'),
|
|
||||||
str_ends_with($normalized, 'count'),
|
|
||||||
str_ends_with($normalized, 'total'),
|
|
||||||
str_ends_with($normalized, 'page'),
|
|
||||||
str_ends_with($normalized, 'limit'),
|
|
||||||
str_ends_with($normalized, 'offset'),
|
|
||||||
str_ends_with($normalized, 'size'),
|
|
||||||
str_ends_with($normalized, 'quantity'),
|
|
||||||
str_ends_with($normalized, 'rank'),
|
|
||||||
str_ends_with($normalized, 'score') => ['type' => 'integer', 'nullable' => true],
|
|
||||||
str_starts_with($normalized, 'is_'),
|
|
||||||
str_starts_with($normalized, 'has_'),
|
|
||||||
str_starts_with($normalized, 'can_'),
|
|
||||||
str_starts_with($normalized, 'should_'),
|
|
||||||
str_starts_with($normalized, 'enabled'),
|
|
||||||
str_starts_with($normalized, 'active') => ['type' => 'boolean', 'nullable' => true],
|
|
||||||
str_ends_with($normalized, '_at'),
|
|
||||||
str_ends_with($normalized, '_on'),
|
|
||||||
str_contains($normalized, 'date'),
|
|
||||||
str_contains($normalized, 'time'),
|
|
||||||
str_contains($normalized, 'timestamp') => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
|
||||||
str_contains($normalized, 'email') => ['type' => 'string', 'format' => 'email', 'nullable' => true],
|
|
||||||
str_contains($normalized, 'url'),
|
|
||||||
str_contains($normalized, 'uri') => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
|
|
||||||
str_contains($normalized, 'uuid') => ['type' => 'string', 'format' => 'uuid', 'nullable' => true],
|
|
||||||
str_contains($normalized, 'name'),
|
|
||||||
str_contains($normalized, 'title'),
|
|
||||||
str_contains($normalized, 'description'),
|
|
||||||
str_contains($normalized, 'status'),
|
|
||||||
str_contains($normalized, 'type'),
|
|
||||||
str_contains($normalized, 'code'),
|
|
||||||
str_contains($normalized, 'token'),
|
|
||||||
str_contains($normalized, 'slug'),
|
|
||||||
str_contains($normalized, 'key') => ['type' => 'string', 'nullable' => true],
|
|
||||||
default => ['nullable' => true],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer a schema for a string value using the field name as a hint.
|
|
||||||
*/
|
|
||||||
protected function inferStringSchema(string $value, ?string $key): array
|
|
||||||
{
|
|
||||||
if ($key !== null) {
|
|
||||||
$nullable = $this->inferNullableSchema($key);
|
|
||||||
|
|
||||||
if (($nullable['type'] ?? null) === 'string') {
|
|
||||||
$nullable['nullable'] = false;
|
|
||||||
return $nullable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['type' => 'string'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer a schema for an object value.
|
|
||||||
*/
|
|
||||||
protected function inferObjectSchema(object $value): array
|
|
||||||
{
|
|
||||||
$properties = [];
|
|
||||||
|
|
||||||
foreach (get_object_vars($value) as $key => $item) {
|
|
||||||
$properties[$key] = $this->inferValueSchema($item, (string) $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => $properties,
|
|
||||||
'additionalProperties' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap schema in pagination structure.
|
* Wrap schema in pagination structure.
|
||||||
*/
|
*/
|
||||||
|
|
@ -870,45 +661,8 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build request body schema.
|
* Build request body schema.
|
||||||
*/
|
*/
|
||||||
protected function buildRequestBody(Route $route, ?object $controller, string $action): array
|
protected function buildRequestBody(?object $controller, string $action): array
|
||||||
{
|
{
|
||||||
if ($controller instanceof \Core\Api\Controllers\McpApiController && $action === 'callTool') {
|
|
||||||
return [
|
|
||||||
'required' => true,
|
|
||||||
'content' => [
|
|
||||||
'application/json' => [
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => [
|
|
||||||
'server' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'maxLength' => 64,
|
|
||||||
'description' => 'MCP server identifier.',
|
|
||||||
],
|
|
||||||
'tool' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'maxLength' => 128,
|
|
||||||
'description' => 'Tool name to invoke on the selected server.',
|
|
||||||
],
|
|
||||||
'arguments' => [
|
|
||||||
'type' => 'object',
|
|
||||||
'description' => 'Tool arguments passed through to MCP.',
|
|
||||||
'additionalProperties' => true,
|
|
||||||
],
|
|
||||||
'version' => [
|
|
||||||
'type' => 'string',
|
|
||||||
'maxLength' => 32,
|
|
||||||
'description' => 'Optional tool version to execute. Defaults to the latest supported version.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'required' => ['server', 'tool'],
|
|
||||||
'additionalProperties' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'content' => [
|
'content' => [
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
|
||||||
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
|
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
|
||||||
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
||||||
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
||||||
Route::get('/stoplight', [DocumentationController::class, 'stoplight'])->name('api.docs.stoplight');
|
|
||||||
|
|
||||||
// OpenAPI specification routes
|
// OpenAPI specification routes
|
||||||
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
|
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="description" content="API Documentation - Stoplight Elements">
|
|
||||||
<title>{{ config('api-docs.info.title', 'API Documentation') }} - Stoplight</title>
|
|
||||||
<style>
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100%;
|
|
||||||
background: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements-api {
|
|
||||||
height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<elements-api
|
|
||||||
apiDescriptionUrl="{{ $specUrl }}"
|
|
||||||
router="hash"
|
|
||||||
layout="{{ $config['layout'] ?? 'sidebar' }}"
|
|
||||||
theme="{{ $config['theme'] ?? 'dark' }}"
|
|
||||||
hideTryIt="{{ ($config['hide_try_it'] ?? false) ? 'true' : 'false' }}"
|
|
||||||
></elements-api>
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -268,13 +268,6 @@ return [
|
||||||
'hide_download_button' => false,
|
'hide_download_button' => false,
|
||||||
'hide_models' => false,
|
'hide_models' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Stoplight Elements specific options
|
|
||||||
'stoplight' => [
|
|
||||||
'theme' => 'dark', // 'dark' or 'light'
|
|
||||||
'layout' => 'sidebar', // 'sidebar' or 'stacked'
|
|
||||||
'hide_try_it' => false,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ namespace Core\Api\Exceptions;
|
||||||
use Core\Api\RateLimit\RateLimitResult;
|
use Core\Api\RateLimit\RateLimitResult;
|
||||||
use Core\Api\Concerns\HasApiResponses;
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,9 +36,9 @@ class RateLimitExceededException extends HttpException
|
||||||
/**
|
/**
|
||||||
* Render the exception as a JSON response.
|
* Render the exception as a JSON response.
|
||||||
*/
|
*/
|
||||||
public function render(?Request $request = null): JsonResponse
|
public function render(): JsonResponse
|
||||||
{
|
{
|
||||||
$response = $this->errorResponse(
|
return $this->errorResponse(
|
||||||
errorCode: 'rate_limit_exceeded',
|
errorCode: 'rate_limit_exceeded',
|
||||||
message: $this->getMessage(),
|
message: $this->getMessage(),
|
||||||
meta: [
|
meta: [
|
||||||
|
|
@ -49,14 +48,6 @@ class RateLimitExceededException extends HttpException
|
||||||
],
|
],
|
||||||
status: 429,
|
status: 429,
|
||||||
)->withHeaders($this->rateLimitResult->headers());
|
)->withHeaders($this->rateLimitResult->headers());
|
||||||
|
|
||||||
if ($request !== null) {
|
|
||||||
$origin = $request->headers->get('Origin', '*');
|
|
||||||
$response->headers->set('Access-Control-Allow-Origin', $origin);
|
|
||||||
$response->headers->set('Vary', 'Origin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class AuthenticateApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return 401 Unauthorised response.
|
* Return 401 Unauthorized response.
|
||||||
*/
|
*/
|
||||||
protected function unauthorized(string $message): Response
|
protected function unauthorized(string $message): Response
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class ErrorResource extends JsonResource
|
||||||
/**
|
/**
|
||||||
* Common error factory methods.
|
* Common error factory methods.
|
||||||
*/
|
*/
|
||||||
public static function unauthorized(string $message = 'Unauthorised'): static
|
public static function unauthorized(string $message = 'Unauthorized'): static
|
||||||
{
|
{
|
||||||
return new static('unauthorized', $message);
|
return new static('unauthorized', $message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Core\Api\Controllers\Api\UnifiedPixelController;
|
|
||||||
use Core\Api\Controllers\Api\EntitlementApiController;
|
|
||||||
use Core\Api\Controllers\Api\SeoReportController;
|
|
||||||
use Core\Api\Controllers\Api\WebhookSecretController;
|
|
||||||
use Core\Api\Controllers\McpApiController;
|
use Core\Api\Controllers\McpApiController;
|
||||||
use Core\Api\Middleware\PublicApiCors;
|
|
||||||
use Core\Mcp\Middleware\McpApiKeyAuth;
|
use Core\Mcp\Middleware\McpApiKeyAuth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
|
@ -18,81 +13,11 @@ use Illuminate\Support\Facades\Route;
|
||||||
|
|
|
|
||||||
| Core API routes for cross-cutting concerns.
|
| Core API routes for cross-cutting concerns.
|
||||||
|
|
|
|
||||||
| SEO, pixel tracking, entitlements, and MCP bridge endpoints.
|
| TODO: SeoReportController, UnifiedPixelController, EntitlementApiController
|
||||||
|
| are planned but not yet implemented. Re-add routes when controllers exist.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Unified Pixel (public tracking)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Route::middleware([PublicApiCors::class, 'api.rate'])
|
|
||||||
->prefix('pixel')
|
|
||||||
->name('api.pixel.')
|
|
||||||
->group(function () {
|
|
||||||
Route::match(['GET', 'POST', 'OPTIONS'], '/{pixelKey}', [UnifiedPixelController::class, 'track'])
|
|
||||||
->name('track');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// SEO analysis (authenticated)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Route::middleware(['auth.api', 'api.scope.enforce'])
|
|
||||||
->prefix('seo')
|
|
||||||
->name('api.seo.')
|
|
||||||
->group(function () {
|
|
||||||
Route::get('/report', [SeoReportController::class, 'show'])
|
|
||||||
->name('report');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Entitlements (authenticated)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Route::middleware(['auth.api', 'api.scope.enforce'])
|
|
||||||
->prefix('entitlements')
|
|
||||||
->name('api.entitlements.')
|
|
||||||
->group(function () {
|
|
||||||
Route::get('/', [EntitlementApiController::class, 'show'])
|
|
||||||
->name('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Webhook secret rotation (authenticated)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Route::middleware(['auth.api', 'api.scope.enforce'])
|
|
||||||
->prefix('webhooks')
|
|
||||||
->name('api.webhooks.')
|
|
||||||
->group(function () {
|
|
||||||
Route::prefix('social/{uuid}/secret')
|
|
||||||
->name('social.')
|
|
||||||
->group(function () {
|
|
||||||
Route::post('/rotate', [WebhookSecretController::class, 'rotateSocialSecret'])
|
|
||||||
->name('rotate-secret');
|
|
||||||
Route::get('/', [WebhookSecretController::class, 'socialSecretStatus'])
|
|
||||||
->name('status');
|
|
||||||
Route::delete('/previous', [WebhookSecretController::class, 'invalidateSocialPreviousSecret'])
|
|
||||||
->name('invalidate-previous');
|
|
||||||
Route::patch('/grace-period', [WebhookSecretController::class, 'updateSocialGracePeriod'])
|
|
||||||
->name('grace-period');
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::prefix('content/{uuid}/secret')
|
|
||||||
->name('content.')
|
|
||||||
->group(function () {
|
|
||||||
Route::post('/rotate', [WebhookSecretController::class, 'rotateContentSecret'])
|
|
||||||
->name('rotate-secret');
|
|
||||||
Route::get('/', [WebhookSecretController::class, 'contentSecretStatus'])
|
|
||||||
->name('status');
|
|
||||||
Route::delete('/previous', [WebhookSecretController::class, 'invalidateContentPreviousSecret'])
|
|
||||||
->name('invalidate-previous');
|
|
||||||
Route::patch('/grace-period', [WebhookSecretController::class, 'updateContentGracePeriod'])
|
|
||||||
->name('grace-period');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MCP HTTP Bridge (API key auth)
|
// MCP HTTP Bridge (API key auth)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -109,8 +34,6 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
|
||||||
->name('servers.show');
|
->name('servers.show');
|
||||||
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
||||||
->name('servers.tools');
|
->name('servers.tools');
|
||||||
Route::get('/servers/{id}/resources', [McpApiController::class, 'resources'])
|
|
||||||
->name('servers.resources');
|
|
||||||
|
|
||||||
// Tool version history (read)
|
// Tool version history (read)
|
||||||
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Api\Services;
|
namespace Mod\Api\Services;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Core\Api\Models\ApiUsage;
|
use Mod\Api\Models\ApiUsage;
|
||||||
use Core\Api\Models\ApiUsageDaily;
|
use Mod\Api\Models\ApiUsageDaily;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Usage Service - tracks and reports API usage metrics.
|
* API Usage Service - tracks and reports API usage metrics.
|
||||||
|
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Core\Api\Services;
|
|
||||||
|
|
||||||
use DOMDocument;
|
|
||||||
use DOMXPath;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SEO report service.
|
|
||||||
*
|
|
||||||
* Fetches a page and extracts the most useful technical SEO signals from it.
|
|
||||||
*/
|
|
||||||
class SeoReportService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Analyse a URL and return a technical SEO report.
|
|
||||||
*/
|
|
||||||
public function analyse(string $url): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'User-Agent' => config('app.name', 'Core API').' SEO Reporter/1.0',
|
|
||||||
'Accept' => 'text/html,application/xhtml+xml',
|
|
||||||
])
|
|
||||||
->timeout((int) config('api.seo.timeout', 10))
|
|
||||||
->get($url);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
throw new RuntimeException('Unable to fetch the requested URL.', 0, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
$html = (string) $response->body();
|
|
||||||
$xpath = $this->loadXPath($html);
|
|
||||||
|
|
||||||
$title = $this->extractSingleText($xpath, '//title');
|
|
||||||
$description = $this->extractMetaContent($xpath, 'description');
|
|
||||||
$canonical = $this->extractLinkHref($xpath, 'canonical');
|
|
||||||
$robots = $this->extractMetaContent($xpath, 'robots');
|
|
||||||
$language = $this->extractHtmlAttribute($xpath, 'lang');
|
|
||||||
$charset = $this->extractCharset($xpath);
|
|
||||||
|
|
||||||
$openGraph = [
|
|
||||||
'title' => $this->extractMetaContent($xpath, 'og:title', 'property'),
|
|
||||||
'description' => $this->extractMetaContent($xpath, 'og:description', 'property'),
|
|
||||||
'image' => $this->extractMetaContent($xpath, 'og:image', 'property'),
|
|
||||||
'type' => $this->extractMetaContent($xpath, 'og:type', 'property'),
|
|
||||||
'site_name' => $this->extractMetaContent($xpath, 'og:site_name', 'property'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$twitterCard = [
|
|
||||||
'card' => $this->extractMetaContent($xpath, 'twitter:card', 'name'),
|
|
||||||
'title' => $this->extractMetaContent($xpath, 'twitter:title', 'name'),
|
|
||||||
'description' => $this->extractMetaContent($xpath, 'twitter:description', 'name'),
|
|
||||||
'image' => $this->extractMetaContent($xpath, 'twitter:image', 'name'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$headings = $this->countHeadings($xpath);
|
|
||||||
$issues = $this->buildIssues($title, $description, $canonical, $robots, $openGraph, $headings);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'url' => $url,
|
|
||||||
'status_code' => $response->status(),
|
|
||||||
'content_type' => $response->header('Content-Type'),
|
|
||||||
'score' => $this->calculateScore($issues),
|
|
||||||
'summary' => [
|
|
||||||
'title' => $title,
|
|
||||||
'description' => $description,
|
|
||||||
'canonical' => $canonical,
|
|
||||||
'robots' => $robots,
|
|
||||||
'language' => $language,
|
|
||||||
'charset' => $charset,
|
|
||||||
],
|
|
||||||
'open_graph' => $openGraph,
|
|
||||||
'twitter' => $twitterCard,
|
|
||||||
'headings' => $headings,
|
|
||||||
'issues' => $issues,
|
|
||||||
'recommendations' => $this->buildRecommendations($issues),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load an HTML document into an XPath query object.
|
|
||||||
*/
|
|
||||||
protected function loadXPath(string $html): DOMXPath
|
|
||||||
{
|
|
||||||
$previous = libxml_use_internal_errors(true);
|
|
||||||
|
|
||||||
$document = new DOMDocument();
|
|
||||||
$document->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING);
|
|
||||||
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($previous);
|
|
||||||
|
|
||||||
return new DOMXPath($document);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the first text node matched by an XPath query.
|
|
||||||
*/
|
|
||||||
protected function extractSingleText(DOMXPath $xpath, string $query): ?string
|
|
||||||
{
|
|
||||||
$nodes = $xpath->query($query);
|
|
||||||
|
|
||||||
if (! $nodes || $nodes->length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node = $nodes->item(0);
|
|
||||||
|
|
||||||
if (! $node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($node->textContent ?? '');
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a meta tag content value.
|
|
||||||
*/
|
|
||||||
protected function extractMetaContent(DOMXPath $xpath, string $name, string $attribute = 'name'): ?string
|
|
||||||
{
|
|
||||||
$query = sprintf('//meta[@%s=%s]/@content', $attribute, $this->quoteForXPath($name));
|
|
||||||
$nodes = $xpath->query($query);
|
|
||||||
|
|
||||||
if (! $nodes || $nodes->length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node = $nodes->item(0);
|
|
||||||
|
|
||||||
if (! $node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($node->textContent ?? '');
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a link href value.
|
|
||||||
*/
|
|
||||||
protected function extractLinkHref(DOMXPath $xpath, string $rel): ?string
|
|
||||||
{
|
|
||||||
$query = sprintf('//link[@rel=%s]/@href', $this->quoteForXPath($rel));
|
|
||||||
$nodes = $xpath->query($query);
|
|
||||||
|
|
||||||
if (! $nodes || $nodes->length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node = $nodes->item(0);
|
|
||||||
|
|
||||||
if (! $node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($node->textContent ?? '');
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the HTML lang attribute.
|
|
||||||
*/
|
|
||||||
protected function extractHtmlAttribute(DOMXPath $xpath, string $attribute): ?string
|
|
||||||
{
|
|
||||||
$nodes = $xpath->query(sprintf('//html/@%s', $attribute));
|
|
||||||
|
|
||||||
if (! $nodes || $nodes->length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node = $nodes->item(0);
|
|
||||||
|
|
||||||
if (! $node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($node->textContent ?? '');
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a charset declaration.
|
|
||||||
*/
|
|
||||||
protected function extractCharset(DOMXPath $xpath): ?string
|
|
||||||
{
|
|
||||||
$nodes = $xpath->query('//meta[@charset]/@charset');
|
|
||||||
|
|
||||||
if ($nodes && $nodes->length > 0) {
|
|
||||||
$node = $nodes->item(0);
|
|
||||||
|
|
||||||
if ($node) {
|
|
||||||
$value = trim($node->textContent ?? '');
|
|
||||||
|
|
||||||
if ($value !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->extractMetaContent($xpath, 'content-type', 'http-equiv');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count headings by level.
|
|
||||||
*
|
|
||||||
* @return array<string, int>
|
|
||||||
*/
|
|
||||||
protected function countHeadings(DOMXPath $xpath): array
|
|
||||||
{
|
|
||||||
$counts = [];
|
|
||||||
|
|
||||||
for ($level = 1; $level <= 6; $level++) {
|
|
||||||
$nodes = $xpath->query('//h'.$level);
|
|
||||||
$counts['h'.$level] = $nodes ? $nodes->length : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build issue list from the extracted SEO data.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, string>>
|
|
||||||
*/
|
|
||||||
protected function buildIssues(
|
|
||||||
?string $title,
|
|
||||||
?string $description,
|
|
||||||
?string $canonical,
|
|
||||||
?string $robots,
|
|
||||||
array $openGraph,
|
|
||||||
array $headings
|
|
||||||
): array {
|
|
||||||
$issues = [];
|
|
||||||
|
|
||||||
if ($title === null) {
|
|
||||||
$issues[] = $this->issue('missing_title', 'No <title> tag was found.', 'high');
|
|
||||||
} elseif (Str::length($title) < 10) {
|
|
||||||
$issues[] = $this->issue('title_too_short', 'The page title is shorter than 10 characters.', 'medium');
|
|
||||||
} elseif (Str::length($title) > 60) {
|
|
||||||
$issues[] = $this->issue('title_too_long', 'The page title is longer than 60 characters.', 'medium');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($description === null) {
|
|
||||||
$issues[] = $this->issue('missing_description', 'No meta description was found.', 'high');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($canonical === null) {
|
|
||||||
$issues[] = $this->issue('missing_canonical', 'No canonical URL was found.', 'medium');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($headings['h1'] ?? 0) === 0) {
|
|
||||||
$issues[] = $this->issue('missing_h1', 'The page does not contain an H1 heading.', 'high');
|
|
||||||
} elseif (($headings['h1'] ?? 0) > 1) {
|
|
||||||
$issues[] = $this->issue('multiple_h1', 'The page contains multiple H1 headings.', 'medium');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($openGraph['title'] ?? null) === null) {
|
|
||||||
$issues[] = $this->issue('missing_og_title', 'No Open Graph title was found.', 'low');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($openGraph['description'] ?? null) === null) {
|
|
||||||
$issues[] = $this->issue('missing_og_description', 'No Open Graph description was found.', 'low');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($robots !== null && Str::contains(Str::lower($robots), ['noindex', 'nofollow'])) {
|
|
||||||
$issues[] = $this->issue('robots_restricted', 'Robots directives block indexing or following links.', 'high');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a list of issues to a report score.
|
|
||||||
*/
|
|
||||||
protected function calculateScore(array $issues): int
|
|
||||||
{
|
|
||||||
$penalties = [
|
|
||||||
'missing_title' => 20,
|
|
||||||
'title_too_short' => 5,
|
|
||||||
'title_too_long' => 5,
|
|
||||||
'missing_description' => 15,
|
|
||||||
'missing_canonical' => 10,
|
|
||||||
'missing_h1' => 15,
|
|
||||||
'multiple_h1' => 5,
|
|
||||||
'missing_og_title' => 5,
|
|
||||||
'missing_og_description' => 5,
|
|
||||||
'robots_restricted' => 20,
|
|
||||||
];
|
|
||||||
|
|
||||||
$score = 100;
|
|
||||||
|
|
||||||
foreach ($issues as $issue) {
|
|
||||||
$score -= $penalties[$issue['code']] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return max(0, $score);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build recommendations from issues.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
protected function buildRecommendations(array $issues): array
|
|
||||||
{
|
|
||||||
$recommendations = [];
|
|
||||||
|
|
||||||
foreach ($issues as $issue) {
|
|
||||||
$recommendations[] = match ($issue['code']) {
|
|
||||||
'missing_title' => 'Add a concise page title that describes the page content.',
|
|
||||||
'title_too_short' => 'Expand the page title so it is more descriptive.',
|
|
||||||
'title_too_long' => 'Shorten the page title to keep it under 60 characters.',
|
|
||||||
'missing_description' => 'Add a meta description to improve search snippets.',
|
|
||||||
'missing_canonical' => 'Add a canonical URL to prevent duplicate content issues.',
|
|
||||||
'missing_h1' => 'Add a single, descriptive H1 heading.',
|
|
||||||
'multiple_h1' => 'Reduce the page to a single primary H1 heading.',
|
|
||||||
'missing_og_title' => 'Add an Open Graph title for better social sharing.',
|
|
||||||
'missing_og_description' => 'Add an Open Graph description for better social sharing.',
|
|
||||||
'robots_restricted' => 'Remove noindex or nofollow directives if the page should be indexed.',
|
|
||||||
default => $issue['message'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($recommendations));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an issue record.
|
|
||||||
*
|
|
||||||
* @return array{code: string, message: string, severity: string}
|
|
||||||
*/
|
|
||||||
protected function issue(string $code, string $message, string $severity): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'code' => $code,
|
|
||||||
'message' => $message,
|
|
||||||
'severity' => $severity,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quote a literal for XPath queries.
|
|
||||||
*/
|
|
||||||
protected function quoteForXPath(string $value): string
|
|
||||||
{
|
|
||||||
if (! str_contains($value, "'")) {
|
|
||||||
return "'{$value}'";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! str_contains($value, '"')) {
|
|
||||||
return '"'.$value.'"';
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = array_map(
|
|
||||||
fn (string $part) => "'{$part}'",
|
|
||||||
explode("'", $value)
|
|
||||||
);
|
|
||||||
|
|
||||||
return 'concat('.implode(", \"'\", ", $parts).')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Core\Api\Models\ApiKey;
|
use Mod\Api\Models\ApiKey;
|
||||||
use Core\Api\Models\ApiUsage;
|
use Mod\Api\Models\ApiUsage;
|
||||||
use Core\Api\Models\ApiUsageDaily;
|
use Mod\Api\Models\ApiUsageDaily;
|
||||||
use Core\Api\Services\ApiUsageService;
|
use Mod\Api\Services\ApiUsageService;
|
||||||
use Core\Tenant\Models\User;
|
use Mod\Tenant\Models\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
it('renders Stoplight Elements when selected as the default documentation ui', function () {
|
|
||||||
config(['api-docs.ui.default' => 'stoplight']);
|
|
||||||
|
|
||||||
$response = $this->get('/api/docs');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertSee('elements-api', false);
|
|
||||||
$response->assertSee('@stoplight/elements', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the dedicated Stoplight documentation route', function () {
|
|
||||||
$response = $this->get('/api/docs/stoplight');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertSee('elements-api', false);
|
|
||||||
$response->assertSee('@stoplight/elements', false);
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Core\Api\Models\ApiKey;
|
|
||||||
use Core\Api\Services\ApiUsageService;
|
|
||||||
use Core\Tenant\Models\User;
|
|
||||||
use Core\Tenant\Models\Workspace;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->workspace = Workspace::factory()->create();
|
|
||||||
$this->workspace->users()->attach($this->user->id, [
|
|
||||||
'role' => 'owner',
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = ApiKey::generate(
|
|
||||||
$this->workspace->id,
|
|
||||||
$this->user->id,
|
|
||||||
'Entitlements Key',
|
|
||||||
[ApiKey::SCOPE_READ]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->plainKey = $result['plain_key'];
|
|
||||||
$this->apiKey = $result['api_key'];
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns entitlement limits and usage for the current workspace', function () {
|
|
||||||
app(ApiUsageService::class)->record(
|
|
||||||
apiKeyId: $this->apiKey->id,
|
|
||||||
workspaceId: $this->workspace->id,
|
|
||||||
endpoint: '/api/entitlements',
|
|
||||||
method: 'GET',
|
|
||||||
statusCode: 200,
|
|
||||||
responseTimeMs: 42,
|
|
||||||
ipAddress: '127.0.0.1',
|
|
||||||
userAgent: 'Pest'
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/entitlements', [
|
|
||||||
'Authorization' => "Bearer {$this->plainKey}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('workspace_id', $this->workspace->id);
|
|
||||||
$response->assertJsonPath('authentication.type', 'api_key');
|
|
||||||
$response->assertJsonPath('limits.api_keys.maximum', config('api.keys.max_per_workspace'));
|
|
||||||
$response->assertJsonPath('limits.api_keys.active', 1);
|
|
||||||
$response->assertJsonPath('usage.totals.requests', 1);
|
|
||||||
$response->assertJsonPath('features.mcp', true);
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Core\Api\Controllers\McpApiController;
|
|
||||||
|
|
||||||
it('includes the requested tool version in the MCP JSON-RPC payload', function () {
|
|
||||||
$controller = new class extends McpApiController
|
|
||||||
{
|
|
||||||
public function payload(string $tool, array $arguments, ?string $version = null): array
|
|
||||||
{
|
|
||||||
return $this->buildToolCallRequest($tool, $arguments, $version);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$payload = $controller->payload('search', ['query' => 'status'], '1.2.3');
|
|
||||||
|
|
||||||
expect($payload['jsonrpc'])->toBe('2.0');
|
|
||||||
expect($payload['method'])->toBe('tools/call');
|
|
||||||
expect($payload['params'])->toMatchArray([
|
|
||||||
'name' => 'search',
|
|
||||||
'arguments' => ['query' => 'status'],
|
|
||||||
'version' => '1.2.3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the version field when one is not requested', function () {
|
|
||||||
$controller = new class extends McpApiController
|
|
||||||
{
|
|
||||||
public function payload(string $tool, array $arguments, ?string $version = null): array
|
|
||||||
{
|
|
||||||
return $this->buildToolCallRequest($tool, $arguments, $version);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$payload = $controller->payload('search', ['query' => 'status']);
|
|
||||||
|
|
||||||
expect($payload['params'])->toMatchArray([
|
|
||||||
'name' => 'search',
|
|
||||||
'arguments' => ['query' => 'status'],
|
|
||||||
]);
|
|
||||||
expect($payload['params'])->not->toHaveKey('version');
|
|
||||||
});
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Mod\Api\Models\ApiKey;
|
|
||||||
use Mod\Tenant\Models\User;
|
|
||||||
use Mod\Tenant\Models\Workspace;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->workspace = Workspace::factory()->create();
|
|
||||||
$this->workspace->users()->attach($this->user->id, [
|
|
||||||
'role' => 'owner',
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = ApiKey::generate(
|
|
||||||
$this->workspace->id,
|
|
||||||
$this->user->id,
|
|
||||||
'MCP Resource Key',
|
|
||||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->plainKey = $result['plain_key'];
|
|
||||||
|
|
||||||
$this->serverId = 'test-resource-server';
|
|
||||||
$this->serverDir = resource_path('mcp/servers');
|
|
||||||
$this->serverFile = $this->serverDir.'/'.$this->serverId.'.yaml';
|
|
||||||
|
|
||||||
if (! is_dir($this->serverDir)) {
|
|
||||||
mkdir($this->serverDir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($this->serverFile, <<<YAML
|
|
||||||
id: test-resource-server
|
|
||||||
name: Test Resource Server
|
|
||||||
status: available
|
|
||||||
resources:
|
|
||||||
- uri: test-resource-server://documents/welcome
|
|
||||||
path: documents/welcome
|
|
||||||
name: welcome
|
|
||||||
content:
|
|
||||||
message: Hello from the MCP resource bridge
|
|
||||||
version: 1
|
|
||||||
YAML);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
|
|
||||||
if (isset($this->serverFile) && is_file($this->serverFile)) {
|
|
||||||
unlink($this->serverFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($this->serverDir) && is_dir($this->serverDir)) {
|
|
||||||
@rmdir($this->serverDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mcpDir = dirname($this->serverDir ?? '');
|
|
||||||
if (is_dir($mcpDir)) {
|
|
||||||
@rmdir($mcpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads a resource from the server definition', function () {
|
|
||||||
$encodedUri = rawurlencode('test-resource-server://documents/welcome');
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/mcp/resources/{$encodedUri}", [
|
|
||||||
'Authorization' => "Bearer {$this->plainKey}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJson([
|
|
||||||
'uri' => 'test-resource-server://documents/welcome',
|
|
||||||
'server' => 'test-resource-server',
|
|
||||||
'resource' => 'documents/welcome',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($response->json('content'))->toBe([
|
|
||||||
'message' => 'Hello from the MCP resource bridge',
|
|
||||||
'version' => 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists resources for a server', function () {
|
|
||||||
$response = $this->getJson('/api/mcp/servers/test-resource-server/resources', [
|
|
||||||
'Authorization' => "Bearer {$this->plainKey}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('server', 'test-resource-server');
|
|
||||||
$response->assertJsonPath('count', 1);
|
|
||||||
$response->assertJsonPath('resources.0.uri', 'test-resource-server://documents/welcome');
|
|
||||||
$response->assertJsonPath('resources.0.path', 'documents/welcome');
|
|
||||||
$response->assertJsonPath('resources.0.name', 'welcome');
|
|
||||||
$response->assertJsonMissingPath('resources.0.content');
|
|
||||||
});
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Core\Mod\Mcp\Services\ToolVersionService;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Mod\Api\Models\ApiKey;
|
|
||||||
use Mod\Tenant\Models\User;
|
|
||||||
use Mod\Tenant\Models\Workspace;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->workspace = Workspace::factory()->create();
|
|
||||||
$this->workspace->users()->attach($this->user->id, [
|
|
||||||
'role' => 'owner',
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = ApiKey::generate(
|
|
||||||
$this->workspace->id,
|
|
||||||
$this->user->id,
|
|
||||||
'MCP Server Detail Key',
|
|
||||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->plainKey = $result['plain_key'];
|
|
||||||
|
|
||||||
$this->serverId = 'test-detail-server';
|
|
||||||
$this->serverDir = resource_path('mcp/servers');
|
|
||||||
$this->serverFile = $this->serverDir.'/'.$this->serverId.'.yaml';
|
|
||||||
|
|
||||||
if (! is_dir($this->serverDir)) {
|
|
||||||
mkdir($this->serverDir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($this->serverFile, <<<YAML
|
|
||||||
id: test-detail-server
|
|
||||||
name: Test Detail Server
|
|
||||||
status: available
|
|
||||||
tools:
|
|
||||||
- name: search
|
|
||||||
description: Search records
|
|
||||||
inputSchema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
query:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- query
|
|
||||||
resources:
|
|
||||||
- uri: test-detail-server://documents/welcome
|
|
||||||
path: documents/welcome
|
|
||||||
name: welcome
|
|
||||||
content:
|
|
||||||
message: Hello from the server detail endpoint
|
|
||||||
version: 2
|
|
||||||
YAML);
|
|
||||||
|
|
||||||
app()->instance(ToolVersionService::class, new class
|
|
||||||
{
|
|
||||||
public function getLatestVersion(string $serverId, string $toolName): object
|
|
||||||
{
|
|
||||||
return (object) [
|
|
||||||
'version' => '2.1.0',
|
|
||||||
'is_deprecated' => false,
|
|
||||||
'input_schema' => [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => [
|
|
||||||
'query' => [
|
|
||||||
'type' => 'string',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'required' => ['query'],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
|
|
||||||
if (isset($this->serverFile) && is_file($this->serverFile)) {
|
|
||||||
unlink($this->serverFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($this->serverDir) && is_dir($this->serverDir)) {
|
|
||||||
@rmdir($this->serverDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mcpDir = dirname($this->serverDir ?? '');
|
|
||||||
if (is_dir($mcpDir)) {
|
|
||||||
@rmdir($mcpDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes tool versions and resource content on server detail requests when requested', function () {
|
|
||||||
$response = $this->getJson('/api/mcp/servers/test-detail-server?include_versions=1&include_content=1', [
|
|
||||||
'Authorization' => "Bearer {$this->plainKey}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('id', 'test-detail-server');
|
|
||||||
$response->assertJsonPath('tools.0.name', 'search');
|
|
||||||
$response->assertJsonPath('tools.0.versioning.latest_version', '2.1.0');
|
|
||||||
$response->assertJsonPath('tools.0.inputSchema.required.0', 'query');
|
|
||||||
$response->assertJsonPath('resources.0.uri', 'test-detail-server://documents/welcome');
|
|
||||||
$response->assertJsonPath('resources.0.content.message', 'Hello from the server detail endpoint');
|
|
||||||
$response->assertJsonPath('resources.0.content.version', 2);
|
|
||||||
$response->assertJsonPath('tool_count', 1);
|
|
||||||
$response->assertJsonPath('resource_count', 1);
|
|
||||||
});
|
|
||||||
|
|
@ -10,7 +10,6 @@ use Core\Api\Documentation\Attributes\ApiTag;
|
||||||
use Core\Api\Documentation\Extension;
|
use Core\Api\Documentation\Extension;
|
||||||
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
||||||
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
||||||
use Core\Api\Documentation\Extensions\SunsetExtension;
|
|
||||||
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||||
use Core\Api\Documentation\OpenApiBuilder;
|
use Core\Api\Documentation\OpenApiBuilder;
|
||||||
use Core\Api\RateLimit\RateLimit;
|
use Core\Api\RateLimit\RateLimit;
|
||||||
|
|
@ -153,26 +152,6 @@ describe('OpenApiBuilder Controller Scanning', function () {
|
||||||
expect($operation['operationId'])->toBe('testScanItemsIndex');
|
expect($operation['operationId'])->toBe('testScanItemsIndex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes duplicate operation IDs unique', function () {
|
|
||||||
RouteFacade::prefix('api')
|
|
||||||
->middleware('api')
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/duplicate-id/dup-one', fn () => response()->json([]));
|
|
||||||
RouteFacade::get('/duplicate-id/dup_one', fn () => response()->json([]));
|
|
||||||
});
|
|
||||||
|
|
||||||
config(['api-docs.routes.include' => ['api/*']]);
|
|
||||||
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$first = $spec['paths']['/api/duplicate-id/dup-one']['get']['operationId'];
|
|
||||||
$second = $spec['paths']['/api/duplicate-id/dup_one']['get']['operationId'];
|
|
||||||
|
|
||||||
expect($first)->not->toBe($second);
|
|
||||||
expect($second)->toEndWith('_2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates summary from route name', function () {
|
it('generates summary from route name', function () {
|
||||||
$builder = new OpenApiBuilder;
|
$builder = new OpenApiBuilder;
|
||||||
$spec = $builder->build();
|
$spec = $builder->build();
|
||||||
|
|
@ -196,116 +175,6 @@ describe('OpenApiBuilder Controller Scanning', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Application Endpoint Parameter Docs
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Application Endpoint Parameter Docs', function () {
|
|
||||||
it('documents the SEO report url query parameter', function () {
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/seo/report']['get'];
|
|
||||||
$urlParam = collect($operation['parameters'] ?? [])->firstWhere('name', 'url');
|
|
||||||
|
|
||||||
expect($urlParam)->not->toBeNull();
|
|
||||||
expect($urlParam['in'])->toBe('query');
|
|
||||||
expect($urlParam['required'])->toBeTrue();
|
|
||||||
expect($urlParam['schema']['format'])->toBe('uri');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents the pixel endpoint as binary for GET and no-content for POST', function () {
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$getOperation = $spec['paths']['/api/pixel/{pixelKey}']['get'];
|
|
||||||
$getResponse = $getOperation['responses']['200'] ?? [];
|
|
||||||
$getContent = $getResponse['content']['image/gif']['schema'] ?? null;
|
|
||||||
|
|
||||||
expect($getContent)->toBe([
|
|
||||||
'type' => 'string',
|
|
||||||
'format' => 'binary',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$postOperation = $spec['paths']['/api/pixel/{pixelKey}']['post'];
|
|
||||||
$postResponse = $postOperation['responses']['204'] ?? [];
|
|
||||||
|
|
||||||
expect($postResponse['description'] ?? null)->toBe('Accepted without a response body');
|
|
||||||
expect($postResponse)->not->toHaveKey('content');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents MCP list query parameters', function () {
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$toolsOperation = $spec['paths']['/api/mcp/servers/{id}/tools']['get'];
|
|
||||||
$includeVersions = collect($toolsOperation['parameters'] ?? [])->firstWhere('name', 'include_versions');
|
|
||||||
|
|
||||||
expect($includeVersions)->not->toBeNull();
|
|
||||||
expect($includeVersions['in'])->toBe('query');
|
|
||||||
expect($includeVersions['schema']['type'])->toBe('boolean');
|
|
||||||
|
|
||||||
$resourcesOperation = $spec['paths']['/api/mcp/servers/{id}/resources']['get'];
|
|
||||||
$includeContent = collect($resourcesOperation['parameters'] ?? [])->firstWhere('name', 'include_content');
|
|
||||||
|
|
||||||
expect($includeContent)->not->toBeNull();
|
|
||||||
expect($includeContent['in'])->toBe('query');
|
|
||||||
expect($includeContent['schema']['type'])->toBe('boolean');
|
|
||||||
|
|
||||||
$serverOperation = $spec['paths']['/api/mcp/servers/{id}']['get'];
|
|
||||||
$serverIncludeVersions = collect($serverOperation['parameters'] ?? [])->firstWhere('name', 'include_versions');
|
|
||||||
$serverIncludeContent = collect($serverOperation['parameters'] ?? [])->firstWhere('name', 'include_content');
|
|
||||||
|
|
||||||
expect($serverIncludeVersions)->not->toBeNull();
|
|
||||||
expect($serverIncludeVersions['in'])->toBe('query');
|
|
||||||
expect($serverIncludeVersions['schema']['type'])->toBe('boolean');
|
|
||||||
|
|
||||||
expect($serverIncludeContent)->not->toBeNull();
|
|
||||||
expect($serverIncludeContent['in'])->toBe('query');
|
|
||||||
expect($serverIncludeContent['schema']['type'])->toBe('boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets explicit path parameter metadata override the generated entry', function () {
|
|
||||||
RouteFacade::prefix('api')
|
|
||||||
->middleware('api')
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/test-scan/items/{id}/explicit', [TestExplicitPathParameterController::class, 'show']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/test-scan/items/{id}/explicit']['get'];
|
|
||||||
$parameters = $operation['parameters'] ?? [];
|
|
||||||
|
|
||||||
expect($parameters)->toHaveCount(1);
|
|
||||||
|
|
||||||
$idParam = collect($parameters)->firstWhere('name', 'id');
|
|
||||||
|
|
||||||
expect($idParam)->not->toBeNull();
|
|
||||||
expect($idParam['in'])->toBe('path');
|
|
||||||
expect($idParam['required'])->toBeTrue();
|
|
||||||
expect($idParam['description'])->toBe('Explicit item identifier');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents the MCP tool call request body shape', function () {
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/mcp/tools/call']['post'];
|
|
||||||
$schema = $operation['requestBody']['content']['application/json']['schema'] ?? null;
|
|
||||||
|
|
||||||
expect($schema)->not->toBeNull();
|
|
||||||
expect($schema['type'])->toBe('object');
|
|
||||||
expect($schema['properties'])->toHaveKey('server')
|
|
||||||
->toHaveKey('tool')
|
|
||||||
->toHaveKey('arguments')
|
|
||||||
->toHaveKey('version');
|
|
||||||
expect($schema['required'])->toBe(['server', 'tool']);
|
|
||||||
expect($schema['additionalProperties'])->toBeTrue();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// ApiParameter Attribute Parsing
|
// ApiParameter Attribute Parsing
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -454,10 +323,9 @@ describe('ApiResponse Attribute Rendering', function () {
|
||||||
201 => 'Resource created',
|
201 => 'Resource created',
|
||||||
204 => 'No content',
|
204 => 'No content',
|
||||||
400 => 'Bad request',
|
400 => 'Bad request',
|
||||||
401 => 'Unauthorised',
|
401 => 'Unauthorized',
|
||||||
403 => 'Forbidden',
|
403 => 'Forbidden',
|
||||||
404 => 'Not found',
|
404 => 'Not found',
|
||||||
410 => 'Gone',
|
|
||||||
422 => 'Validation error',
|
422 => 'Validation error',
|
||||||
429 => 'Too many requests',
|
429 => 'Too many requests',
|
||||||
500 => 'Internal server error',
|
500 => 'Internal server error',
|
||||||
|
|
@ -479,29 +347,6 @@ describe('ApiResponse Attribute Rendering', function () {
|
||||||
expect($response->resource)->toBe(TestJsonResource::class);
|
expect($response->resource)->toBe(TestJsonResource::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('infers resource schema fields from JsonResource payloads', function () {
|
|
||||||
config(['api-docs.routes.include' => ['api/*']]);
|
|
||||||
config(['api-docs.routes.exclude' => []]);
|
|
||||||
|
|
||||||
RouteFacade::prefix('api')
|
|
||||||
->middleware('api')
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$schema = $spec['paths']['/api/test-scan/items/{id}']['get']['responses']['200']['content']['application/json']['schema'] ?? null;
|
|
||||||
|
|
||||||
expect($schema)->not->toBeNull();
|
|
||||||
expect($schema['type'])->toBe('object');
|
|
||||||
expect($schema['properties'])->toHaveKey('id')
|
|
||||||
->toHaveKey('name');
|
|
||||||
expect($schema['properties']['id']['type'])->toBe('integer');
|
|
||||||
expect($schema['properties']['name']['type'])->toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports paginated flag', function () {
|
it('supports paginated flag', function () {
|
||||||
$response = new ApiResponse(
|
$response = new ApiResponse(
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
@ -804,7 +649,7 @@ describe('Extension System', function () {
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Error Response Documentation', function () {
|
describe('Error Response Documentation', function () {
|
||||||
it('documents 401 Unauthorised response', function () {
|
it('documents 401 Unauthorized response', function () {
|
||||||
$extension = new ApiKeyAuthExtension;
|
$extension = new ApiKeyAuthExtension;
|
||||||
$spec = [
|
$spec = [
|
||||||
'info' => [],
|
'info' => [],
|
||||||
|
|
@ -866,69 +711,6 @@ describe('Error Response Documentation', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Sunset Documentation
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Sunset Documentation', function () {
|
|
||||||
it('registers deprecation headers in components', function () {
|
|
||||||
$extension = new SunsetExtension;
|
|
||||||
$spec = ['components' => []];
|
|
||||||
|
|
||||||
$result = $extension->extend($spec, []);
|
|
||||||
|
|
||||||
expect($result['components']['headers'])->toHaveKey('deprecation')
|
|
||||||
->toHaveKey('sunset')
|
|
||||||
->toHaveKey('link')
|
|
||||||
->toHaveKey('xapiwarn');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks sunset routes as deprecated and documents their response headers', function () {
|
|
||||||
RouteFacade::prefix('api')
|
|
||||||
->middleware(['api', 'api.sunset:2025-06-01,/api/v2/legacy'])
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/sunset-test/legacy', fn () => response()->json(['ok' => true]))
|
|
||||||
->name('sunset-test.legacy');
|
|
||||||
});
|
|
||||||
|
|
||||||
config(['api-docs.routes.include' => ['api/*']]);
|
|
||||||
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/sunset-test/legacy']['get'];
|
|
||||||
|
|
||||||
expect($operation['deprecated'])->toBeTrue();
|
|
||||||
expect($operation['responses']['200']['headers'])->toHaveKey('Deprecation')
|
|
||||||
->toHaveKey('Sunset')
|
|
||||||
->toHaveKey('X-API-Warn')
|
|
||||||
->toHaveKey('Link');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only documents the sunset headers that the middleware will emit', function () {
|
|
||||||
RouteFacade::prefix('api')
|
|
||||||
->middleware(['api', 'api.sunset'])
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/sunset-test/plain', fn () => response()->json(['ok' => true]))
|
|
||||||
->name('sunset-test.plain');
|
|
||||||
});
|
|
||||||
|
|
||||||
config(['api-docs.routes.include' => ['api/*']]);
|
|
||||||
|
|
||||||
$builder = new OpenApiBuilder;
|
|
||||||
$spec = $builder->build();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/sunset-test/plain']['get'];
|
|
||||||
$headers = $operation['responses']['200']['headers'];
|
|
||||||
|
|
||||||
expect($operation['deprecated'])->toBeTrue();
|
|
||||||
expect($headers)->toHaveKey('Deprecation')
|
|
||||||
->toHaveKey('X-API-Warn');
|
|
||||||
expect($headers)->not->toHaveKey('Sunset');
|
|
||||||
expect($headers)->not->toHaveKey('Link');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Authentication Documentation
|
// Authentication Documentation
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1245,16 +1027,6 @@ class TestPartialHiddenController
|
||||||
public function hiddenMethod(): void {}
|
public function hiddenMethod(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Test controller with an explicit path parameter override.
|
|
||||||
*/
|
|
||||||
class TestExplicitPathParameterController
|
|
||||||
{
|
|
||||||
#[ApiParameter('id', 'path', 'string', 'Explicit item identifier')]
|
|
||||||
#[ApiResponse(200, TestJsonResource::class, 'Item details')]
|
|
||||||
public function show(string $id): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test tagged controller.
|
* Test tagged controller.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,6 @@ class OpenApiDocumentationTest extends TestCase
|
||||||
$response = new ApiResponse(404);
|
$response = new ApiResponse(404);
|
||||||
|
|
||||||
$this->assertEquals('Not found', $response->getDescription());
|
$this->assertEquals('Not found', $response->getDescription());
|
||||||
|
|
||||||
$goneResponse = new ApiResponse(410);
|
|
||||||
|
|
||||||
$this->assertEquals('Gone', $goneResponse->getDescription());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_api_security_attribute(): void
|
public function test_api_security_attribute(): void
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Core\Api\Documentation\OpenApiBuilder;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
Config::set('api.headers.include_version', true);
|
|
||||||
Config::set('api.headers.include_deprecation', true);
|
|
||||||
Config::set('api.versioning.deprecated', [1]);
|
|
||||||
Config::set('api.versioning.sunset', [
|
|
||||||
1 => '2025-06-01',
|
|
||||||
]);
|
|
||||||
Config::set('api-docs.routes.include', ['api/*']);
|
|
||||||
Config::set('api-docs.routes.exclude', []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents version headers and version-driven deprecation on versioned routes', function () {
|
|
||||||
RouteFacade::prefix('api/v1')
|
|
||||||
->middleware(['api', 'api.version:1'])
|
|
||||||
->group(function () {
|
|
||||||
RouteFacade::get('/legacy-status', fn () => response()->json(['ok' => true]));
|
|
||||||
});
|
|
||||||
|
|
||||||
$spec = (new OpenApiBuilder)->build();
|
|
||||||
|
|
||||||
expect($spec['components']['headers']['xapiversion'] ?? null)->not->toBeNull();
|
|
||||||
|
|
||||||
$operation = $spec['paths']['/api/v1/legacy-status']['get'];
|
|
||||||
|
|
||||||
expect($operation['deprecated'] ?? null)->toBeTrue();
|
|
||||||
|
|
||||||
foreach (['200', '400', '500'] as $status) {
|
|
||||||
$headers = $operation['responses'][$status]['headers'] ?? [];
|
|
||||||
|
|
||||||
expect($headers)->toHaveKey('X-API-Version');
|
|
||||||
expect($headers)->toHaveKey('Deprecation');
|
|
||||||
expect($headers)->toHaveKey('Sunset');
|
|
||||||
expect($headers)->toHaveKey('X-API-Warn');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function () {
|
|
||||||
Cache::flush();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a transparent gif for get requests', function () {
|
|
||||||
$response = $this->get('/api/pixel/abc12345', [
|
|
||||||
'Origin' => 'https://example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertHeader('Content-Type', 'image/gif');
|
|
||||||
$response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
|
|
||||||
$response->assertHeader('X-RateLimit-Limit', '10000');
|
|
||||||
$response->assertHeader('X-RateLimit-Remaining', '9999');
|
|
||||||
|
|
||||||
expect($response->getContent())->toBe(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts post tracking requests without a body', function () {
|
|
||||||
$response = $this->post('/api/pixel/abc12345', [], [
|
|
||||||
'Origin' => 'https://example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertNoContent();
|
|
||||||
$response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
|
|
||||||
$response->assertHeader('X-RateLimit-Limit', '10000');
|
|
||||||
$response->assertHeader('X-RateLimit-Remaining', '9999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles preflight requests for public pixel tracking', function () {
|
|
||||||
$response = $this->call('OPTIONS', '/api/pixel/abc12345', [], [], [], [
|
|
||||||
'HTTP_ORIGIN' => 'https://example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertNoContent();
|
|
||||||
$response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
|
|
||||||
$response->assertHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
});
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Core\Api\Models\ApiKey;
|
|
||||||
use Core\Tenant\Models\User;
|
|
||||||
use Core\Tenant\Models\Workspace;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
$this->user = User::factory()->create();
|
|
||||||
$this->workspace = Workspace::factory()->create();
|
|
||||||
$this->workspace->users()->attach($this->user->id, [
|
|
||||||
'role' => 'owner',
|
|
||||||
'is_default' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = ApiKey::generate(
|
|
||||||
$this->workspace->id,
|
|
||||||
$this->user->id,
|
|
||||||
'SEO Key',
|
|
||||||
[ApiKey::SCOPE_READ]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->plainKey = $result['plain_key'];
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a technical SEO report for a URL', function () {
|
|
||||||
Http::fake([
|
|
||||||
'https://example.com*' => Http::response(<<<'HTML'
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Example Product Landing Page</title>
|
|
||||||
<meta name="description" content="A concise example description for the landing page.">
|
|
||||||
<link rel="canonical" href="https://example.com/landing-page">
|
|
||||||
<meta property="og:title" content="Example Product Landing Page">
|
|
||||||
<meta property="og:description" content="A concise example description for the landing page.">
|
|
||||||
<meta property="og:image" content="https://example.com/og-image.jpg">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:site_name" content="Example">
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Example Product Landing Page</h1>
|
|
||||||
<h2>Key Features</h2>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
HTML, 200, [
|
|
||||||
'Content-Type' => 'text/html; charset=utf-8',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/seo/report?url=https://example.com', [
|
|
||||||
'Authorization' => "Bearer {$this->plainKey}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('data.url', 'https://example.com');
|
|
||||||
$response->assertJsonPath('data.status_code', 200);
|
|
||||||
$response->assertJsonPath('data.summary.title', 'Example Product Landing Page');
|
|
||||||
$response->assertJsonPath('data.summary.description', 'A concise example description for the landing page.');
|
|
||||||
$response->assertJsonPath('data.headings.h1', 1);
|
|
||||||
$response->assertJsonPath('data.open_graph.site_name', 'Example');
|
|
||||||
$response->assertJsonPath('data.score', 100);
|
|
||||||
$response->assertJsonPath('data.issues', []);
|
|
||||||
});
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
it('registers webhook secret management routes', function () {
|
|
||||||
$socialRotate = Route::getRoutes()->getByName('api.webhooks.social.rotate-secret');
|
|
||||||
$socialStatus = Route::getRoutes()->getByName('api.webhooks.social.status');
|
|
||||||
$contentRotate = Route::getRoutes()->getByName('api.webhooks.content.rotate-secret');
|
|
||||||
$contentStatus = Route::getRoutes()->getByName('api.webhooks.content.status');
|
|
||||||
|
|
||||||
expect($socialRotate)->not->toBeNull();
|
|
||||||
expect($socialRotate->uri())->toBe('api/webhooks/social/{uuid}/secret/rotate');
|
|
||||||
expect($socialRotate->methods())->toContain('POST');
|
|
||||||
|
|
||||||
expect($socialStatus)->not->toBeNull();
|
|
||||||
expect($socialStatus->uri())->toBe('api/webhooks/social/{uuid}/secret');
|
|
||||||
expect($socialStatus->methods())->toContain('GET');
|
|
||||||
|
|
||||||
expect($contentRotate)->not->toBeNull();
|
|
||||||
expect($contentRotate->uri())->toBe('api/webhooks/content/{uuid}/secret/rotate');
|
|
||||||
expect($contentRotate->methods())->toContain('POST');
|
|
||||||
|
|
||||||
expect($contentStatus)->not->toBeNull();
|
|
||||||
expect($contentStatus->uri())->toBe('api/webhooks/content/{uuid}/secret');
|
|
||||||
expect($contentStatus->methods())->toContain('GET');
|
|
||||||
});
|
|
||||||
|
|
@ -220,20 +220,6 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| SEO Analysis
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Settings for the SEO report and analysis endpoint.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'seo' => [
|
|
||||||
// HTTP timeout when fetching a page for analysis
|
|
||||||
'timeout' => env('API_SEO_TIMEOUT', 10),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Pagination
|
| Pagination
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue