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 (
|
||||
"context"
|
||||
"errors"
|
||||
"iter"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"github.com/gin-contrib/expvar"
|
||||
"github.com/gin-contrib/pprof"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -25,57 +24,27 @@ const defaultAddr = ":8080"
|
|||
const shutdownTimeout = 10 * time.Second
|
||||
|
||||
// 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 {
|
||||
addr string
|
||||
groups []RouteGroup
|
||||
middlewares []gin.HandlerFunc
|
||||
cacheTTL time.Duration
|
||||
cacheMaxEntries int
|
||||
cacheMaxBytes int
|
||||
wsHandler http.Handler
|
||||
wsPath string
|
||||
sseBroker *SSEBroker
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerSummary string
|
||||
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
|
||||
addr string
|
||||
groups []RouteGroup
|
||||
middlewares []gin.HandlerFunc
|
||||
wsHandler http.Handler
|
||||
sseBroker *SSEBroker
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerDesc string
|
||||
swaggerVersion string
|
||||
pprofEnabled bool
|
||||
expvarEnabled bool
|
||||
graphql *graphqlConfig
|
||||
}
|
||||
|
||||
// New creates an Engine with the given options.
|
||||
// The default listen address is ":8080".
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, err := api.New(api.WithAddr(":8081"), api.WithResponseMeta())
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// engine, _ := api.New(api.WithAddr(":9090"), api.WithCORS("*"))
|
||||
// engine.Register(myGroup)
|
||||
// engine.Serve(ctx)
|
||||
func New(opts ...Option) (*Engine, error) {
|
||||
e := &Engine{
|
||||
addr: defaultAddr,
|
||||
|
|
@ -88,77 +57,49 @@ func New(opts ...Option) (*Engine, error) {
|
|||
|
||||
// Addr returns the configured listen address.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithAddr(":9090"))
|
||||
// addr := engine.Addr()
|
||||
// addr := engine.Addr() // ":9090"
|
||||
func (e *Engine) Addr() string {
|
||||
return e.addr
|
||||
}
|
||||
|
||||
// Groups returns a copy of all registered route groups.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// groups := engine.Groups()
|
||||
// Groups returns all registered route groups.
|
||||
func (e *Engine) Groups() []RouteGroup {
|
||||
return slices.Clone(e.groups)
|
||||
return e.groups
|
||||
}
|
||||
|
||||
// GroupsIter returns an iterator over all registered route groups.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// for group := range engine.GroupsIter() {
|
||||
// _ = group
|
||||
// }
|
||||
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
|
||||
groups := slices.Clone(e.groups)
|
||||
return slices.Values(groups)
|
||||
return slices.Values(e.groups)
|
||||
}
|
||||
|
||||
// Register adds a route group to the engine.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine.Register(myGroup)
|
||||
// engine.Register(api.NewToolBridge("/tools"))
|
||||
// engine.Register(myRouteGroup)
|
||||
func (e *Engine) Register(group RouteGroup) {
|
||||
if isNilRouteGroup(group) {
|
||||
return
|
||||
}
|
||||
e.groups = append(e.groups, group)
|
||||
}
|
||||
|
||||
// Channels returns all WebSocket channel names from registered StreamGroups.
|
||||
// Groups that do not implement StreamGroup are silently skipped.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// channels := engine.Channels()
|
||||
func (e *Engine) Channels() []string {
|
||||
var channels []string
|
||||
for _, g := range e.groups {
|
||||
if sg, ok := g.(StreamGroup); ok {
|
||||
channels = append(channels, sg.Channels()...)
|
||||
for _, group := range e.groups {
|
||||
if streamGroup, ok := group.(StreamGroup); ok {
|
||||
channels = append(channels, streamGroup.Channels()...)
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
// 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] {
|
||||
groups := slices.Clone(e.groups)
|
||||
return func(yield func(string) bool) {
|
||||
for _, g := range groups {
|
||||
if sg, ok := g.(StreamGroup); ok {
|
||||
for _, c := range sg.Channels() {
|
||||
if !yield(c) {
|
||||
for _, group := range e.groups {
|
||||
if streamGroup, ok := group.(StreamGroup); ok {
|
||||
for _, channelName := range streamGroup.Channels() {
|
||||
if !yield(channelName) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -170,53 +111,35 @@ func (e *Engine) ChannelsIter() iter.Seq[string] {
|
|||
// Handler builds the Gin engine and returns it as an http.Handler.
|
||||
// Each call produces a fresh handler reflecting the current set of groups.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// handler := engine.Handler()
|
||||
// http.ListenAndServe(":8080", engine.Handler())
|
||||
func (e *Engine) Handler() http.Handler {
|
||||
return e.build()
|
||||
}
|
||||
|
||||
// Serve starts the HTTP server and blocks until the context is cancelled,
|
||||
// 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 {
|
||||
srv := &http.Server{
|
||||
server := &http.Server{
|
||||
Addr: e.addr,
|
||||
Handler: e.build(),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
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
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
// Return immediately if the listener fails before shutdown is requested.
|
||||
select {
|
||||
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()
|
||||
}
|
||||
// Block until context is cancelled.
|
||||
<-ctx.Done()
|
||||
|
||||
// Graceful shutdown with timeout.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
if err := server.Shutdown(shutdownContext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -227,71 +150,54 @@ func (e *Engine) Serve(ctx context.Context) error {
|
|||
// build creates a configured Gin engine with recovery middleware,
|
||||
// user-supplied middleware, the health endpoint, and all registered route groups.
|
||||
func (e *Engine) build() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(recoveryMiddleware())
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
// Apply user-supplied middleware after recovery but before routes.
|
||||
for _, mw := range e.middlewares {
|
||||
r.Use(mw)
|
||||
for _, middleware := range e.middlewares {
|
||||
router.Use(middleware)
|
||||
}
|
||||
|
||||
// Built-in health check.
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, OK("healthy"))
|
||||
})
|
||||
|
||||
// Mount each registered group at its base path.
|
||||
for _, g := range e.groups {
|
||||
if isNilRouteGroup(g) {
|
||||
continue
|
||||
}
|
||||
rg := r.Group(g.BasePath())
|
||||
g.RegisterRoutes(rg)
|
||||
for _, group := range e.groups {
|
||||
routerGroup := router.Group(group.BasePath())
|
||||
group.RegisterRoutes(routerGroup)
|
||||
}
|
||||
|
||||
// Mount WebSocket handler if configured.
|
||||
if e.wsHandler != nil {
|
||||
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
|
||||
router.GET("/ws", wrapWSHandler(e.wsHandler))
|
||||
}
|
||||
|
||||
// Mount SSE endpoint if configured.
|
||||
if e.sseBroker != nil {
|
||||
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
|
||||
router.GET("/events", e.sseBroker.Handler())
|
||||
}
|
||||
|
||||
// Mount GraphQL endpoint if configured.
|
||||
if e.graphql != nil {
|
||||
mountGraphQL(r, e.graphql)
|
||||
mountGraphQL(router, e.graphql)
|
||||
}
|
||||
|
||||
// Mount Swagger UI if enabled.
|
||||
if e.swaggerEnabled {
|
||||
registerSwagger(r, e, e.groups)
|
||||
registerSwagger(router, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
|
||||
}
|
||||
|
||||
// Mount pprof profiling endpoints if enabled.
|
||||
if e.pprofEnabled {
|
||||
pprof.Register(r)
|
||||
pprof.Register(router)
|
||||
}
|
||||
|
||||
// Mount expvar runtime metrics endpoint if enabled.
|
||||
if e.expvarEnabled {
|
||||
r.GET("/debug/vars", expvar.Handler())
|
||||
router.GET("/debug/vars", expvar.Handler())
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
|
|
|||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reserve port: %v", err)
|
||||
}
|
||||
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)
|
||||
func TestNew_Ugly_MultipleOptionsDontPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("New with many options panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case serveErr := <-errCh:
|
||||
if serveErr == nil {
|
||||
t.Fatal("expected Serve to return a listen error, got nil")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("Serve did not return promptly after listener failure")
|
||||
// Applying many options at once should not panic.
|
||||
_, err := api.New(
|
||||
api.WithAddr(":0"),
|
||||
api.WithRequestID(),
|
||||
api.WithCORS("*"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
authentik.go
138
authentik.go
|
|
@ -6,18 +6,14 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthentikConfig holds settings for the Authentik forward-auth integration.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cfg := api.AuthentikConfig{Issuer: "https://auth.example.com/", ClientID: "core-api"}
|
||||
type AuthentikConfig struct {
|
||||
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
|
||||
Issuer string
|
||||
|
|
@ -30,32 +26,12 @@ type AuthentikConfig struct {
|
|||
TrustedProxy bool
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// forward-auth headers or a validated JWT.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// user := &api.AuthentikUser{Username: "alice", Groups: []string{"admins"}}
|
||||
type AuthentikUser struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
|
|
@ -68,9 +44,8 @@ type AuthentikUser struct {
|
|||
|
||||
// HasGroup reports whether the user belongs to the named group.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// user.HasGroup("admins")
|
||||
// user := api.GetUser(c)
|
||||
// if user.HasGroup("admins") { /* allow */ }
|
||||
func (u *AuthentikUser) HasGroup(group string) bool {
|
||||
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
|
||||
// middleware not active).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// user := api.GetUser(c)
|
||||
// if user == nil { c.AbortWithStatus(401); return }
|
||||
func GetUser(c *gin.Context) *AuthentikUser {
|
||||
val, exists := c.Get(authentikUserKey)
|
||||
if !exists {
|
||||
|
|
@ -110,28 +84,28 @@ func getOIDCProvider(ctx context.Context, issuer string) (*oidc.Provider, error)
|
|||
oidcProviderMu.Lock()
|
||||
defer oidcProviderMu.Unlock()
|
||||
|
||||
if p, ok := oidcProviders[issuer]; ok {
|
||||
return p, nil
|
||||
if provider, ok := oidcProviders[issuer]; ok {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
p, err := oidc.NewProvider(ctx, issuer)
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcProviders[issuer] = p
|
||||
return p, nil
|
||||
oidcProviders[issuer] = provider
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// validateJWT verifies a raw JWT against the configured OIDC issuer and
|
||||
// extracts user claims on success.
|
||||
func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*AuthentikUser, error) {
|
||||
provider, err := getOIDCProvider(ctx, cfg.Issuer)
|
||||
func validateJWT(ctx context.Context, config AuthentikConfig, rawToken string) (*AuthentikUser, error) {
|
||||
provider, err := getOIDCProvider(ctx, config.Issuer)
|
||||
if err != nil {
|
||||
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)
|
||||
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
|
||||
// present but never rejects unauthenticated requests. Downstream handlers
|
||||
// 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.
|
||||
public := map[string]bool{
|
||||
"/health": true,
|
||||
"/swagger": true,
|
||||
}
|
||||
for _, p := range cfg.PublicPaths {
|
||||
public[p] = true
|
||||
for _, publicPath := range config.PublicPaths {
|
||||
public[publicPath] = true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Skip public paths.
|
||||
path := c.Request.URL.Path
|
||||
for p := range public {
|
||||
if isPublicPath(path, p) {
|
||||
for publicPath := range public {
|
||||
if core.HasPrefix(path, publicPath) {
|
||||
c.Next()
|
||||
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.
|
||||
if cfg.TrustedProxy {
|
||||
if config.TrustedProxy {
|
||||
username := c.GetHeader("X-authentik-username")
|
||||
if username != "" {
|
||||
user := &AuthentikUser{
|
||||
|
|
@ -207,10 +173,10 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H
|
|||
}
|
||||
|
||||
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 != "" {
|
||||
user.Entitlements = strings.Split(ent, "|")
|
||||
user.Entitlements = core.Split(ent, "|")
|
||||
}
|
||||
|
||||
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.
|
||||
// Only when OIDC is configured and no user was extracted from headers.
|
||||
if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil {
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
rawToken := strings.TrimPrefix(auth, "Bearer ")
|
||||
if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil {
|
||||
if config.Issuer != "" && config.ClientID != "" && GetUser(c) == nil {
|
||||
if auth := c.GetHeader("Authorization"); core.HasPrefix(auth, "Bearer ") {
|
||||
rawToken := core.TrimPrefix(auth, "Bearer ")
|
||||
if user, err := validateJWT(c.Request.Context(), config, rawToken); err == nil {
|
||||
c.Set(authentikUserKey, user)
|
||||
}
|
||||
// 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.
|
||||
// It checks for a user set by the Authentik middleware and returns 401
|
||||
// when none is present.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// r.GET("/private", api.RequireAuth(), handler)
|
||||
// rg := router.Group("/api", api.RequireAuth())
|
||||
// rg.GET("/profile", profileHandler)
|
||||
func RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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
|
||||
// and 403 when the user lacks the required group membership.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// r.GET("/admin", api.RequireGroup("admins"), handler)
|
||||
// rg := router.Group("/admin", api.RequireGroup("admins"))
|
||||
// rg.DELETE("/users/:id", deleteUserHandler)
|
||||
func RequireGroup(group string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := GetUser(c)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,14 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
|
@ -43,58 +41,68 @@ func getClientCredentialsToken(t *testing.T, issuer, clientID, clientSecret stri
|
|||
t.Helper()
|
||||
|
||||
// Discover token endpoint.
|
||||
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||
resp, err := http.Get(disc)
|
||||
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||
resp, err := http.Get(discoveryURL) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("OIDC discovery failed: %v", err)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
t.Fatalf("decode discovery: %v", err)
|
||||
if result := core.JSONUnmarshal(discoveryBody, &oidcConfig); !result.OK {
|
||||
t.Fatalf("decode discovery: %v", result.Value)
|
||||
}
|
||||
|
||||
// Request token.
|
||||
data := url.Values{
|
||||
formData := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {clientSecret},
|
||||
"scope": {"openid email profile entitlements"},
|
||||
}
|
||||
resp, err = http.PostForm(config.TokenEndpoint, data)
|
||||
tokenResp, err := http.PostForm(oidcConfig.TokenEndpoint, formData) //nolint:noctx
|
||||
if err != nil {
|
||||
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"`
|
||||
IDToken string `json:"id_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
t.Fatalf("decode token response: %v", err)
|
||||
if result := core.JSONUnmarshal(tokenBody, &tokenResult); !result.OK {
|
||||
t.Fatalf("decode token response: %v", result.Value)
|
||||
}
|
||||
if tokenResp.Error != "" {
|
||||
t.Fatalf("token error: %s — %s", tokenResp.Error, tokenResp.ErrorDesc)
|
||||
if tokenResult.Error != "" {
|
||||
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.
|
||||
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
|
||||
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
||||
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
||||
}
|
||||
|
||||
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||
clientID := envOr("AUTHENTIK_CLIENT_ID", "core-api")
|
||||
clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET")
|
||||
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||
clientID := envOrDefault("AUTHENTIK_CLIENT_ID", "core-api")
|
||||
clientSecret := core.Env("AUTHENTIK_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
t.Fatal("AUTHENTIK_CLIENT_SECRET is required")
|
||||
}
|
||||
|
|
@ -126,60 +134,60 @@ func TestAuthentikIntegration(t *testing.T) {
|
|||
t.Fatalf("engine: %v", err)
|
||||
}
|
||||
engine.Register(&testAuthRoutes{})
|
||||
ts := httptest.NewServer(engine.Handler())
|
||||
defer ts.Close()
|
||||
testServer := httptest.NewServer(engine.Handler())
|
||||
defer testServer.Close()
|
||||
|
||||
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
|
||||
|
||||
t.Run("Health_NoAuth", func(t *testing.T) {
|
||||
resp := get(t, ts.URL+"/health", "")
|
||||
assertStatus(t, resp, 200)
|
||||
body := readBody(t, resp)
|
||||
resp := getWithBearer(t, testServer.URL+"/health", "")
|
||||
assertStatusCode(t, resp, 200)
|
||||
body := readResponseBody(t, resp)
|
||||
t.Logf("health: %s", body)
|
||||
})
|
||||
|
||||
t.Run("Public_NoAuth", func(t *testing.T) {
|
||||
resp := get(t, ts.URL+"/v1/public", "")
|
||||
assertStatus(t, resp, 200)
|
||||
body := readBody(t, resp)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/public", "")
|
||||
assertStatusCode(t, resp, 200)
|
||||
body := readResponseBody(t, resp)
|
||||
t.Logf("public: %s", body)
|
||||
})
|
||||
|
||||
t.Run("Whoami_NoToken_401", func(t *testing.T) {
|
||||
resp := get(t, ts.URL+"/v1/whoami", "")
|
||||
assertStatus(t, resp, 401)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "")
|
||||
assertStatusCode(t, resp, 401)
|
||||
})
|
||||
|
||||
t.Run("Whoami_WithAccessToken", func(t *testing.T) {
|
||||
resp := get(t, ts.URL+"/v1/whoami", accessToken)
|
||||
assertStatus(t, resp, 200)
|
||||
body := readBody(t, resp)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/whoami", accessToken)
|
||||
assertStatusCode(t, resp, 200)
|
||||
body := readResponseBody(t, resp)
|
||||
t.Logf("whoami (access_token): %s", body)
|
||||
|
||||
// Parse response and verify user fields.
|
||||
var envelope struct {
|
||||
Data api.AuthentikUser `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
||||
t.Fatalf("parse whoami: %v", err)
|
||||
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
||||
t.Fatalf("parse whoami: %v", result.Value)
|
||||
}
|
||||
if envelope.Data.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.Run("Admin_ServiceAccount_403", func(t *testing.T) {
|
||||
// Service account has no groups — should get 403.
|
||||
resp := get(t, ts.URL+"/v1/admin", accessToken)
|
||||
assertStatus(t, resp, 403)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/admin", accessToken)
|
||||
assertStatusCode(t, resp, 403)
|
||||
})
|
||||
|
||||
t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) {
|
||||
// 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-email", "mafiafire@proton.me")
|
||||
req.Header.Set("X-authentik-name", "Admin User")
|
||||
|
|
@ -192,16 +200,16 @@ func TestAuthentikIntegration(t *testing.T) {
|
|||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
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)
|
||||
|
||||
var envelope struct {
|
||||
Data api.AuthentikUser `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
||||
t.Fatalf("parse: %v", result.Value)
|
||||
}
|
||||
if envelope.Data.Username != "akadmin" {
|
||||
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) {
|
||||
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-email", "mafiafire@proton.me")
|
||||
req.Header.Set("X-authentik-name", "Admin User")
|
||||
|
|
@ -224,72 +232,72 @@ func TestAuthentikIntegration(t *testing.T) {
|
|||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assertStatus(t, resp, 200)
|
||||
t.Logf("admin (forward auth): %s", readBody(t, resp))
|
||||
assertStatusCode(t, resp, 200)
|
||||
t.Logf("admin (forward auth): %s", readResponseBody(t, resp))
|
||||
})
|
||||
|
||||
t.Run("InvalidJWT_FailOpen", func(t *testing.T) {
|
||||
// Invalid token on a public endpoint — should still work (permissive).
|
||||
resp := get(t, ts.URL+"/v1/public", "not-a-real-token")
|
||||
assertStatus(t, resp, 200)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/public", "not-a-real-token")
|
||||
assertStatusCode(t, resp, 200)
|
||||
})
|
||||
|
||||
t.Run("InvalidJWT_Protected_401", func(t *testing.T) {
|
||||
// Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401.
|
||||
resp := get(t, ts.URL+"/v1/whoami", "not-a-real-token")
|
||||
assertStatus(t, resp, 401)
|
||||
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "not-a-real-token")
|
||||
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()
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req, _ := http.NewRequest("GET", requestURL, nil)
|
||||
if bearerToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
t.Fatalf("GET %s: %v", requestURL, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
func readResponseBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
responseBytes, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
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()
|
||||
if resp.StatusCode != want {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
responseBytes, _ := io.ReadAll(resp.Body)
|
||||
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 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if value := core.Env(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// TestOIDCDiscovery validates that the OIDC discovery endpoint is reachable.
|
||||
func TestOIDCDiscovery(t *testing.T) {
|
||||
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
|
||||
// TestOIDCDiscovery_Good_EndpointReachable validates that the OIDC discovery endpoint is reachable.
|
||||
func TestOIDCDiscovery_Good_EndpointReachable(t *testing.T) {
|
||||
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
||||
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
||||
}
|
||||
|
||||
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
||||
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||
|
||||
resp, err := http.Get(disc)
|
||||
resp, err := http.Get(discoveryURL) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("discovery request: %v", err)
|
||||
}
|
||||
|
|
@ -299,39 +307,70 @@ func TestOIDCDiscovery(t *testing.T) {
|
|||
t.Fatalf("discovery status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var config map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
discoveryBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if config["issuer"] != issuer {
|
||||
t.Errorf("issuer mismatch: got %v, want %s", config["issuer"], issuer)
|
||||
if discoveryConfig["issuer"] != issuer {
|
||||
t.Errorf("issuer mismatch: got %v, want %s", discoveryConfig["issuer"], issuer)
|
||||
}
|
||||
|
||||
// Verify grant types include client_credentials.
|
||||
grants, ok := config["grant_types_supported"].([]any)
|
||||
grants, ok := discoveryConfig["grant_types_supported"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("missing grant_types_supported")
|
||||
}
|
||||
found := false
|
||||
for _, g := range grants {
|
||||
if g == "client_credentials" {
|
||||
found = true
|
||||
clientCredentialsFound := false
|
||||
for _, grantType := range grants {
|
||||
if grantType == "client_credentials" {
|
||||
clientCredentialsFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if !clientCredentialsFound {
|
||||
t.Error("client_credentials grant not supported")
|
||||
}
|
||||
|
||||
fmt.Printf(" OIDC discovery OK — issuer: %s\n", config["issuer"])
|
||||
fmt.Printf(" Token endpoint: %s\n", config["token_endpoint"])
|
||||
fmt.Printf(" JWKS URI: %s\n", config["jwks_uri"])
|
||||
t.Logf("OIDC discovery OK — issuer: %s", discoveryConfig["issuer"])
|
||||
t.Logf("Token endpoint: %s", discoveryConfig["token_endpoint"])
|
||||
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) {
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
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
|
||||
// middleware even though its path shares a prefix with a public path.
|
||||
type publicPrefixGroup struct{}
|
||||
func TestAuthentikUser_Ugly_EmptyGroupsDontPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("HasGroup on empty groups panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
func (g *publicPrefixGroup) Name() string { return "public-prefix" }
|
||||
func (g *publicPrefixGroup) BasePath() string { return "/publicity" }
|
||||
func (g *publicPrefixGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/secure", api.RequireAuth(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, api.OK("protected"))
|
||||
})
|
||||
u := api.AuthentikUser{}
|
||||
// HasGroup on a zero-value user (nil Groups slice) must not panic.
|
||||
if u.HasGroup("admins") {
|
||||
t.Fatal("expected HasGroup to return false for empty user")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
|
||||
Description string // Human-readable description
|
||||
|
|
@ -37,10 +19,6 @@ type ToolDescriptor struct {
|
|||
|
||||
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
|
||||
// It implements both RouteGroup and DescribableGroup.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bridge := api.NewToolBridge("/mcp")
|
||||
type ToolBridge struct {
|
||||
basePath string
|
||||
name string
|
||||
|
|
@ -52,14 +30,11 @@ type boundTool struct {
|
|||
handler gin.HandlerFunc
|
||||
}
|
||||
|
||||
var _ RouteGroup = (*ToolBridge)(nil)
|
||||
var _ DescribableGroup = (*ToolBridge)(nil)
|
||||
|
||||
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bridge := api.NewToolBridge("/mcp")
|
||||
// bridge := api.NewToolBridge("/tools")
|
||||
// bridge.Add(api.ToolDescriptor{Name: "file_read"}, fileReadHandler)
|
||||
// engine.Register(bridge)
|
||||
func NewToolBridge(basePath string) *ToolBridge {
|
||||
return &ToolBridge{
|
||||
basePath: basePath,
|
||||
|
|
@ -69,70 +44,63 @@ func NewToolBridge(basePath string) *ToolBridge {
|
|||
|
||||
// Add registers a tool with its HTTP handler.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bridge.Add(api.ToolDescriptor{Name: "ping", Description: "Ping the service"}, handler)
|
||||
// bridge.Add(api.ToolDescriptor{Name: "file_read", Group: "files"}, fileReadHandler)
|
||||
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})
|
||||
}
|
||||
|
||||
// Name returns the bridge identifier.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// name := bridge.Name()
|
||||
func (b *ToolBridge) Name() string { return b.name }
|
||||
|
||||
// BasePath returns the URL prefix for all tool endpoints.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// path := bridge.BasePath()
|
||||
func (b *ToolBridge) BasePath() string { return b.basePath }
|
||||
|
||||
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bridge.RegisterRoutes(rg)
|
||||
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
for _, t := range b.tools {
|
||||
rg.POST("/"+t.descriptor.Name, t.handler)
|
||||
for _, tool := range b.tools {
|
||||
rg.POST("/"+tool.descriptor.Name, tool.handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Describe returns OpenAPI route descriptions for all registered tools.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// descs := bridge.Describe()
|
||||
func (b *ToolBridge) Describe() []RouteDescription {
|
||||
tools := b.snapshotTools()
|
||||
descs := make([]RouteDescription, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
descs = append(descs, describeTool(tool.descriptor, b.name))
|
||||
descs := make([]RouteDescription, 0, len(b.tools))
|
||||
for _, tool := range b.tools {
|
||||
tags := []string{tool.descriptor.Group}
|
||||
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
|
||||
}
|
||||
|
||||
// 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] {
|
||||
tools := b.snapshotTools()
|
||||
return func(yield func(RouteDescription) bool) {
|
||||
for _, tool := range tools {
|
||||
if !yield(describeTool(tool.descriptor, b.name)) {
|
||||
for _, tool := range b.tools {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -140,746 +108,21 @@ func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
|||
}
|
||||
|
||||
// Tools returns all registered tool descriptors.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// descs := bridge.Tools()
|
||||
func (b *ToolBridge) Tools() []ToolDescriptor {
|
||||
tools := b.snapshotTools()
|
||||
descs := make([]ToolDescriptor, len(tools))
|
||||
for i, t := range tools {
|
||||
descs[i] = t.descriptor
|
||||
descs := make([]ToolDescriptor, len(b.tools))
|
||||
for i, tool := range b.tools {
|
||||
descs[i] = tool.descriptor
|
||||
}
|
||||
return descs
|
||||
}
|
||||
|
||||
// ToolsIter returns an iterator over all registered tool descriptors.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// for desc := range bridge.ToolsIter() {
|
||||
// _ = desc
|
||||
// }
|
||||
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
|
||||
tools := b.snapshotTools()
|
||||
return func(yield func(ToolDescriptor) bool) {
|
||||
for _, tool := range tools {
|
||||
for _, tool := range b.tools {
|
||||
if !yield(tool.descriptor) {
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"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) {
|
||||
bridge := api.NewToolBridge("/tools")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/andybalholm/brotli"
|
||||
"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.
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,3 +130,27 @@ func TestWithBrotli_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
|||
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 (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -19,57 +17,34 @@ type cacheEntry struct {
|
|||
status int
|
||||
headers http.Header
|
||||
body []byte
|
||||
size int
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
|
||||
type cacheStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
order *list.List
|
||||
index map[string]*list.Element
|
||||
maxEntries int
|
||||
maxBytes int
|
||||
currentBytes int
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
// newCacheStore creates an empty cache store.
|
||||
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
|
||||
func newCacheStore() *cacheStore {
|
||||
return &cacheStore{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
order: list.New(),
|
||||
index: make(map[string]*list.Element),
|
||||
maxEntries: maxEntries,
|
||||
maxBytes: maxBytes,
|
||||
entries: make(map[string]*cacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// get retrieves a non-expired entry for the given key.
|
||||
// Returns nil if the key is missing or expired.
|
||||
func (s *cacheStore) get(key string) *cacheEntry {
|
||||
s.mu.Lock()
|
||||
s.mu.RLock()
|
||||
entry, ok := s.entries[key]
|
||||
if ok {
|
||||
if elem, exists := s.index[key]; exists {
|
||||
s.order.MoveToFront(elem)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(entry.expires) {
|
||||
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)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
|
|
@ -80,81 +55,10 @@ func (s *cacheStore) get(key string) *cacheEntry {
|
|||
// set stores a cache entry with the given TTL.
|
||||
func (s *cacheStore) set(key string, entry *cacheEntry) {
|
||||
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
|
||||
elem := s.order.PushFront(key)
|
||||
s.index[key] = elem
|
||||
s.currentBytes += entry.size
|
||||
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.
|
||||
type cacheWriter struct {
|
||||
gin.ResponseWriter
|
||||
|
|
@ -185,31 +89,14 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
|||
|
||||
// Serve from cache if a valid entry exists.
|
||||
if entry := store.get(key); entry != nil {
|
||||
body := entry.body
|
||||
if meta := GetRequestMeta(c); meta != nil {
|
||||
body = refreshCachedResponseMeta(entry.body, meta)
|
||||
}
|
||||
|
||||
for k, vals := range entry.headers {
|
||||
if http.CanonicalHeaderKey(k) == "X-Request-ID" {
|
||||
continue
|
||||
for headerName, headerValues := range entry.headers {
|
||||
for _, headerValue := range headerValues {
|
||||
c.Writer.Header().Set(headerName, headerValue)
|
||||
}
|
||||
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("Content-Length", strconv.Itoa(len(body)))
|
||||
c.Writer.WriteHeader(entry.status)
|
||||
_, _ = c.Writer.Write(body)
|
||||
_, _ = c.Writer.Write(entry.body)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
|
@ -232,28 +119,8 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
|||
status: status,
|
||||
headers: headers,
|
||||
body: cw.body.Bytes(),
|
||||
size: cacheEntrySize(headers, cw.body.Bytes()),
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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)
|
||||
|
||||
grp := &cacheCounterGroup{}
|
||||
e, _ := api.New(api.WithCache(5*time.Second, 1))
|
||||
e.Register(grp)
|
||||
engine, _ := api.New(api.WithCache(50 * time.Millisecond))
|
||||
engine.Register(grp)
|
||||
handler := engine.Handler()
|
||||
|
||||
h := e.Handler()
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
||||
h.ServeHTTP(w1, req1)
|
||||
if !strings.Contains(w1.Body.String(), "call-1") {
|
||||
t.Fatalf("expected first response to contain %q, got %q", "call-1", w1.Body.String())
|
||||
// Fire many concurrent GET requests; none should deadlock.
|
||||
done := make(chan struct{})
|
||||
for requestIndex := 0; requestIndex < 20; requestIndex++ {
|
||||
go func() {
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil)
|
||||
h.ServeHTTP(w2, req2)
|
||||
if !strings.Contains(w2.Body.String(), "other-2") {
|
||||
t.Fatalf("expected second response to contain %q, got %q", "other-2", w2.Body.String())
|
||||
}
|
||||
|
||||
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())
|
||||
for requestIndex := 0; requestIndex < 20; requestIndex++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("concurrent requests deadlocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
||||
// AddAPICommands registers the `api` command group.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// root := &cli.Command{Use: "root"}
|
||||
// api.AddAPICommands(root)
|
||||
// AddAPICommands registers the 'api' command group.
|
||||
func AddAPICommands(root *cli.Command) {
|
||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"os"
|
||||
"strings"
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
corelog "dappco.re/go/core/log"
|
||||
|
||||
goapi "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSDKTitle = "Lethean Core API"
|
||||
defaultSDKDescription = "Lethean Core API"
|
||||
defaultSDKVersion = "1.0.0"
|
||||
)
|
||||
|
||||
func addSDKCommand(parent *cli.Command) {
|
||||
var (
|
||||
lang string
|
||||
output string
|
||||
specFile 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 {
|
||||
languages := splitUniqueCSV(lang)
|
||||
if len(languages) == 0 {
|
||||
return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
|
||||
if lang == "" {
|
||||
return coreerr.E("sdk.Generate", "--lang is required. Supported: "+core.Join(", ", goapi.SupportedLanguages()...), nil)
|
||||
}
|
||||
|
||||
gen := &goapi.SDKGenerator{
|
||||
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 no spec file provided, generate one to a temp file.
|
||||
if specFile == "" {
|
||||
builder, err := sdkSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
builder := &goapi.SpecBuilder{
|
||||
Title: "Lethean Core API",
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
writer.Close()
|
||||
defer coreio.Local.Delete(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.
|
||||
for _, l := range languages {
|
||||
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
|
||||
if err := gen.Generate(cli.Context(), l); err != nil {
|
||||
return coreerr.E("sdk.Generate", "generate "+l, err)
|
||||
for _, language := range core.Split(lang, ",") {
|
||||
language = core.Trim(language)
|
||||
if language == "" {
|
||||
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
|
||||
|
|
@ -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, &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")
|
||||
registerSpecBuilderFlags(cmd, &cfg)
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
corelog "dappco.re/go/core/log"
|
||||
|
||||
goapi "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
func addSpecCommand(parent *cli.Command) {
|
||||
var (
|
||||
output string
|
||||
format string
|
||||
cfg specBuilderConfig
|
||||
output string
|
||||
format string
|
||||
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 {
|
||||
// Build spec from all route groups registered for CLI generation.
|
||||
builder, err := newSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
// Build spec from registered route groups.
|
||||
// Additional groups can be added here as the platform grows.
|
||||
builder := &goapi.SpecBuilder{
|
||||
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")
|
||||
groups := specGroupsIter(bridge)
|
||||
groups := []goapi.RouteGroup{bridge}
|
||||
|
||||
if output != "" {
|
||||
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
||||
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
||||
corelog.Info("spec written to " + output)
|
||||
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, &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)
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
coreexec "dappco.re/go/core/process/exec"
|
||||
coreprocess "dappco.re/go/core/process"
|
||||
)
|
||||
|
||||
// Supported SDK target languages.
|
||||
|
|
@ -34,9 +33,8 @@ var supportedLanguages = map[string]string{
|
|||
|
||||
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// gen := &api.SDKGenerator{SpecPath: "./openapi.yaml", OutputDir: "./sdk", PackageName: "service"}
|
||||
// gen := &api.SDKGenerator{SpecPath: "./openapi.json", OutputDir: "./sdk", PackageName: "myapi"}
|
||||
// if gen.Available() { gen.Generate(ctx, "go") }
|
||||
type SDKGenerator struct {
|
||||
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
||||
SpecPath string
|
||||
|
|
@ -46,57 +44,48 @@ type SDKGenerator struct {
|
|||
|
||||
// PackageName is the name used for the generated package/module.
|
||||
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.
|
||||
// The language must be one of the supported languages returned by SupportedLanguages().
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := gen.Generate(context.Background(), "go")
|
||||
// err := gen.Generate(ctx, "go")
|
||||
// err := gen.Generate(ctx, "python")
|
||||
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]
|
||||
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 specPath == "" {
|
||||
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)
|
||||
if !coreio.Local.IsFile(g.SpecPath) {
|
||||
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+g.SpecPath, nil)
|
||||
}
|
||||
|
||||
outputBase := strings.TrimSpace(g.OutputDir)
|
||||
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)
|
||||
outputDir := core.Path(g.OutputDir, language)
|
||||
if err := coreio.Local.EnsureDir(outputDir); err != nil {
|
||||
return coreerr.E("SDKGenerator.Generate", "create output directory", err)
|
||||
}
|
||||
|
||||
args := g.buildArgs(generator, outputDir)
|
||||
cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
stdout := g.Stdout
|
||||
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 {
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if !gen.Available() {
|
||||
// t.Fatal("openapi-generator-cli is required")
|
||||
// }
|
||||
// if gen.Available() { gen.Generate(ctx, "go") }
|
||||
func (g *SDKGenerator) Available() bool {
|
||||
_, err := exec.LookPath("openapi-generator-cli")
|
||||
return err == nil
|
||||
prog := &coreprocess.Program{Name: "openapi-generator-cli"}
|
||||
return prog.Find() == nil
|
||||
}
|
||||
|
||||
// SupportedLanguages returns the list of supported SDK target languages
|
||||
// in sorted order for deterministic output.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// langs := api.SupportedLanguages()
|
||||
// langs := api.SupportedLanguages() // ["csharp", "go", "java", ...]
|
||||
func SupportedLanguages() []string {
|
||||
return slices.Sorted(maps.Keys(supportedLanguages))
|
||||
}
|
||||
|
||||
// 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] {
|
||||
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) {
|
||||
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.
|
||||
specDir := t.TempDir()
|
||||
specPath := filepath.Join(specDir, "spec.json")
|
||||
|
|
@ -174,8 +73,8 @@ func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
|||
OutputDir: outputDir,
|
||||
}
|
||||
|
||||
// Generate will fail at the exec step, but the output directory should have
|
||||
// been created before the CLI returned its non-zero status.
|
||||
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
|
||||
// but the output directory should have been created before that.
|
||||
_ = gen.Generate(context.Background(), "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.
|
||||
_ = 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
|
||||
swaggerDesc string
|
||||
swaggerVersion string
|
||||
swaggerExternalDocsDescription string
|
||||
swaggerExternalDocsURL string
|
||||
pprofEnabled bool
|
||||
expvarEnabled bool
|
||||
graphql *graphqlConfig
|
||||
|
|
@ -130,9 +128,6 @@ type RouteDescription struct {
|
|||
Summary string
|
||||
Description string
|
||||
Tags []string
|
||||
Deprecated bool
|
||||
StatusCode int
|
||||
Parameters []ParameterDescription
|
||||
RequestBody 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` |
|
||||
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
|
||||
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
|
||||
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -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 |
|
||||
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
|
||||
| `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 |
|
||||
| `WithCacheLimits(ttl, maxEntries, maxBytes)` | In-memory GET response caching with explicit bounds | Clearer cache configuration when eviction policy should be self-documenting |
|
||||
| `WithCache(ttl)` | In-memory GET response caching | `X-Cache: HIT` header on cache hits; 2xx only |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
`WithCacheLimits(ttl, maxEntries, maxBytes)` installs a URL-keyed in-memory response cache scoped to GET requests:
|
||||
|
||||
```go
|
||||
engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
|
||||
```
|
||||
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests:
|
||||
|
||||
- Only successful 2xx responses are cached.
|
||||
- Non-GET methods pass through uncached.
|
||||
- Cached responses are served with an `X-Cache: HIT` header.
|
||||
- Expired entries are evicted lazily on the next access for the same key.
|
||||
- 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.
|
||||
- Passing non-positive values to `WithCacheLimits` leaves that limit unbounded.
|
||||
- There is no size limit on the cache.
|
||||
|
||||
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
|
||||
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 |
|
||||
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
|
||||
| `--title` | `-t` | `Lethean Core API` | API title |
|
||||
| `--description` | `-d` | `Lethean Core API` | API description |
|
||||
| `--version` | `-V` | `1.0.0` | API version |
|
||||
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
|
||||
|
||||
### `core api sdk`
|
||||
|
||||
|
|
@ -605,10 +585,6 @@ Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`.
|
|||
| `--output` | `-o` | `./sdk` | Output directory |
|
||||
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
|
||||
| `--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
|
||||
|
||||
### 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
|
||||
payload size, but it still stores responses in memory. Workloads with very large cached bodies
|
||||
or a long-lived process will still consume RAM, so a disk-backed cache would be the next step if
|
||||
that becomes a concern.
|
||||
`WithCache(ttl)` stores all successful GET responses in memory with no maximum entry count or
|
||||
total size bound. For a server receiving requests to many distinct URLs, the cache will grow
|
||||
without bound. A LRU eviction policy or a configurable maximum is the natural next step.
|
||||
|
||||
### 2. SDK codegen requires an external binary
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ func main() {
|
|||
api.WithSecure(),
|
||||
api.WithSlog(nil),
|
||||
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
|
||||
|
|
@ -95,7 +94,7 @@ engine.Register(&Routes{service: svc})
|
|||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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` |
|
||||
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
||||
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
||||
|
|
|
|||
94
export.go
94
export.go
|
|
@ -3,112 +3,58 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// ExportSpec generates the OpenAPI spec and writes it to w.
|
||||
// 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 {
|
||||
data, err := builder.Build(groups)
|
||||
if err != nil {
|
||||
return coreerr.E("ExportSpec", "build spec", err)
|
||||
}
|
||||
|
||||
return writeSpec(w, format, data, "ExportSpec")
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
switch format {
|
||||
case "json":
|
||||
_, err := w.Write(data)
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
case "yaml":
|
||||
// Unmarshal JSON then re-marshal as YAML.
|
||||
var obj any
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return coreerr.E(op, "unmarshal spec", err)
|
||||
result := core.JSONUnmarshal(data, &obj)
|
||||
if !result.OK {
|
||||
return coreerr.E("ExportSpec", "unmarshal spec", result.Value.(error))
|
||||
}
|
||||
enc := yaml.NewEncoder(w)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(obj); err != nil {
|
||||
return coreerr.E(op, "encode yaml", err)
|
||||
encoder := yaml.NewEncoder(w)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(obj); err != nil {
|
||||
return coreerr.E("ExportSpec", "encode yaml", err)
|
||||
}
|
||||
return enc.Close()
|
||||
return encoder.Close()
|
||||
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.
|
||||
// The parent directory is created if it does not exist.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())
|
||||
// err := api.ExportSpecToFile("./docs/openapi.json", "json", builder, groups)
|
||||
// err := api.ExportSpecToFile("./docs/openapi.yaml", "yaml", builder, groups)
|
||||
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
||||
return exportSpecToFile(path, "ExportSpecToFile", func(w io.Writer) error {
|
||||
return ExportSpec(w, format, builder, groups)
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err := coreio.Local.EnsureDir(core.PathDir(path)); err != nil {
|
||||
return coreerr.E("ExportSpecToFile", "create directory", err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
writer, err := coreio.Local.Create(path)
|
||||
if err != nil {
|
||||
return coreerr.E(op, "create file", err)
|
||||
return coreerr.E("ExportSpecToFile", "create file", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := f.Close(); closeErr != nil && err == nil {
|
||||
err = coreerr.E(op, "close file", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = write(f); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
defer writer.Close()
|
||||
return ExportSpec(writer, format, builder, groups)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ package api_test
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"iter"
|
||||
"net/http"
|
||||
"os"
|
||||
"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) {
|
||||
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) {
|
||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
||||
func TestExportSpec_Ugly_EmptyFormatDoesNotPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ExportSpec with empty format panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
group := &specStubGroup{
|
||||
name: "iter",
|
||||
basePath: "/iter",
|
||||
descs: []api.RouteDescription{
|
||||
{
|
||||
Method: "GET",
|
||||
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")
|
||||
builder := &api.SpecBuilder{Title: "Test", Version: "1.0.0"}
|
||||
var output strings.Builder
|
||||
// Unknown format should return an error, not panic.
|
||||
err := api.ExportSpec(&output, "xml", builder, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported format, got nil")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,3 +139,27 @@ func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) {
|
|||
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
|
||||
|
||||
require (
|
||||
dappco.re/go/core/io v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
dappco.re/go/core/cli v0.3.7
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/io v0.2.0
|
||||
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/andybalholm/brotli v1.2.0
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
|
|
@ -38,10 +40,10 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.3.2 // indirect
|
||||
dappco.re/go/core/i18n v0.1.7 // indirect
|
||||
dappco.re/go/core/inference v0.1.7 // indirect
|
||||
dappco.re/go/core/log v0.0.4 // indirect
|
||||
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
|
|
@ -132,6 +134,7 @@ require (
|
|||
replace (
|
||||
dappco.re/go/core => ../go
|
||||
dappco.re/go/core/i18n => ../go-i18n
|
||||
dappco.re/go/core/io => ./go-io
|
||||
dappco.re/go/core/log => ./go-log
|
||||
dappco.re/go/core/io => ../go-io
|
||||
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 (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
|
|
@ -22,114 +21,43 @@ type graphqlConfig struct {
|
|||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// opts := []api.GraphQLOption{api.WithPlayground(), api.WithGraphQLPath("/gql")}
|
||||
type GraphQLOption func(*graphqlConfig)
|
||||
|
||||
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.WithGraphQL(schema, api.WithPlayground())
|
||||
func WithPlayground() GraphQLOption {
|
||||
return func(cfg *graphqlConfig) {
|
||||
cfg.playground = true
|
||||
return func(config *graphqlConfig) {
|
||||
config.playground = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
|
||||
// The default path is "/graphql".
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.WithGraphQL(schema, api.WithGraphQLPath("/gql"))
|
||||
func WithGraphQLPath(path string) GraphQLOption {
|
||||
return func(cfg *graphqlConfig) {
|
||||
cfg.path = normaliseGraphQLPath(path)
|
||||
return func(config *graphqlConfig) {
|
||||
config.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// mountGraphQL registers the GraphQL handler and optional playground on the Gin engine.
|
||||
func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) {
|
||||
srv := handler.NewDefaultServer(cfg.schema)
|
||||
graphqlHandler := gin.WrapH(srv)
|
||||
func mountGraphQL(router *gin.Engine, config *graphqlConfig) {
|
||||
graphqlServer := handler.NewDefaultServer(config.schema)
|
||||
graphqlHandler := gin.WrapH(graphqlServer)
|
||||
|
||||
// Mount the GraphQL endpoint for all HTTP methods (POST for queries/mutations,
|
||||
// GET for playground redirects and introspection).
|
||||
r.Any(cfg.path, graphqlHandler)
|
||||
router.Any(config.path, graphqlHandler)
|
||||
|
||||
if cfg.playground {
|
||||
playgroundPath := cfg.path + "/playground"
|
||||
playgroundHandler := playground.Handler("GraphQL", cfg.path)
|
||||
r.GET(playgroundPath, wrapHTTPHandler(playgroundHandler))
|
||||
if config.playground {
|
||||
playgroundPath := config.path + "/playground"
|
||||
playgroundHandler := playground.Handler("GraphQL", config.path)
|
||||
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.
|
||||
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
|
||||
func wrapHTTPHandler(handler http.Handler) gin.HandlerFunc {
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"iter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RouteGroup registers API routes onto a Gin router group.
|
||||
// Subsystems implement this interface to declare their endpoints.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var g api.RouteGroup = &myGroup{}
|
||||
type RouteGroup interface {
|
||||
// Name returns a human-readable identifier for the group.
|
||||
Name() string
|
||||
|
|
@ -26,10 +18,6 @@ type RouteGroup interface {
|
|||
}
|
||||
|
||||
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var sg api.StreamGroup = &myStreamGroup{}
|
||||
type StreamGroup interface {
|
||||
// Channels returns the list of channel names this group streams on.
|
||||
Channels() []string
|
||||
|
|
@ -38,89 +26,19 @@ type StreamGroup interface {
|
|||
// DescribableGroup extends RouteGroup with OpenAPI metadata.
|
||||
// RouteGroups that implement this will have their endpoints
|
||||
// included in the generated OpenAPI specification.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var dg api.DescribableGroup = &myDescribableGroup{}
|
||||
type DescribableGroup interface {
|
||||
RouteGroup
|
||||
// Describe returns endpoint descriptions for OpenAPI generation.
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
||||
Path string // Path relative to BasePath, e.g. "/generate"
|
||||
Summary string // Short summary
|
||||
Description string // Long description
|
||||
Tags []string // OpenAPI tags for grouping
|
||||
// Hidden omits the route from generated documentation.
|
||||
Hidden bool
|
||||
// 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.
|
||||
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
||||
Path string // Path relative to BasePath, e.g. "/generate"
|
||||
Summary string // Short summary
|
||||
Description string // Long description
|
||||
Tags []string // OpenAPI tags for grouping
|
||||
RequestBody map[string]any // JSON Schema for request body (nil for GET)
|
||||
Response map[string]any // JSON Schema for success response data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,3 +224,28 @@ func TestDescribableGroup_Bad_NilSchemas(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
|
@ -16,21 +13,7 @@ const i18nContextKey = "i18n.locale"
|
|||
// i18nMessagesKey is the Gin context key for the message lookup map.
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cfg := api.I18nConfig{
|
||||
// DefaultLocale: "en",
|
||||
// Supported: []string{"en", "fr"},
|
||||
// Messages: map[string]map[string]string{"fr": {"greeting": "Bonjour"}},
|
||||
// }
|
||||
type I18nConfig struct {
|
||||
// DefaultLocale is the fallback locale when the Accept-Language header
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
|
||||
// can replace the message map later.
|
||||
|
|
@ -88,23 +50,21 @@ func WithI18n(cfg ...I18nConfig) Option {
|
|||
|
||||
// Build the language.Matcher from supported locales.
|
||||
tags := []language.Tag{language.Make(config.DefaultLocale)}
|
||||
for _, s := range config.Supported {
|
||||
tag := language.Make(s)
|
||||
for _, supportedLocale := range config.Supported {
|
||||
tag := language.Make(supportedLocale)
|
||||
// Avoid duplicating the default if it also appears in Supported.
|
||||
if tag != tags[0] {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
snapshot := cloneI18nConfig(config)
|
||||
e.i18nConfig = snapshot
|
||||
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
|
||||
// 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 {
|
||||
return func(c *gin.Context) {
|
||||
accept := c.GetHeader("Accept-Language")
|
||||
|
|
@ -115,17 +75,19 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
|||
} else {
|
||||
tags, _, _ := language.ParseAcceptLanguage(accept)
|
||||
tag, _, _ := matcher.Match(tags...)
|
||||
locale = tag.String()
|
||||
base, _ := tag.Base()
|
||||
locale = base.String()
|
||||
}
|
||||
|
||||
c.Set(i18nContextKey, locale)
|
||||
c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale)
|
||||
|
||||
// Attach the message map for this locale if messages are configured.
|
||||
if cfg.Messages != nil {
|
||||
c.Set(i18nCatalogKey, cfg.Messages)
|
||||
if msgs, ok := cfg.Messages[locale]; ok {
|
||||
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.
|
||||
// Returns "en" if the i18n middleware was not applied.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// locale := api.GetLocale(c)
|
||||
func GetLocale(c *gin.Context) string {
|
||||
if v, ok := c.Get(i18nContextKey); 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.
|
||||
// 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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// msg, ok := api.GetMessage(c, "greeting")
|
||||
func GetMessage(c *gin.Context, key string) (string, bool) {
|
||||
if v, ok := c.Get(i18nMessagesKey); 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
|
||||
}
|
||||
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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)
|
||||
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()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
|
||||
req.Header.Set("Accept-Language", "fr-CA")
|
||||
h.ServeHTTP(w, req)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("i18n middleware panicked on malformed Accept-Language: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
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.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{
|
||||
engine, err := api.New(api.WithI18n(api.I18nConfig{
|
||||
DefaultLocale: "en",
|
||||
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()
|
||||
if snap.DefaultLocale != "en" {
|
||||
t.Fatalf("expected default locale en, got %q", snap.DefaultLocale)
|
||||
}
|
||||
if !slices.Equal(snap.Supported, []string{"en", "fr"}) {
|
||||
t.Fatalf("expected supported locales [en fr], got %v", snap.Supported)
|
||||
}
|
||||
if snap.Messages["fr"]["greeting"] != "Bonjour" {
|
||||
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)
|
||||
recorder := httptest.NewRecorder()
|
||||
// Gibberish Accept-Language should fall back to default, not panic.
|
||||
request, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
||||
request.Header.Set("Accept-Language", ";;;invalid;;;")
|
||||
engine.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,3 +178,27 @@ func TestWithLocation_Good_BothHeadersCombined(t *testing.T) {
|
|||
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 (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"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.
|
||||
// Requests to paths in the skip list are allowed through without authentication.
|
||||
// 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) {
|
||||
// Check whether the request path should bypass authentication.
|
||||
for _, path := range skip() {
|
||||
if isPublicPath(c.Request.URL.Path, path) {
|
||||
for _, path := range skip {
|
||||
if core.HasPrefix(c.Request.URL.Path, path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
|
@ -54,8 +30,8 @@ func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token {
|
||||
parts := core.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || core.Lower(parts[0]) != "bearer" || parts[1] != token {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
|
||||
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.
|
||||
// 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".
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(requestStartContextKey, time.Now())
|
||||
|
||||
id := c.GetHeader("X-Request-ID")
|
||||
if id == "" {
|
||||
b := make([]byte, 16)
|
||||
|
|
@ -102,63 +52,8 @@ func requestIDMiddleware() gin.HandlerFunc {
|
|||
id = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
c.Set(requestIDContextKey, id)
|
||||
c.Set("request_id", id)
|
||||
c.Header("X-Request-ID", id)
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
||||
|
|
@ -113,8 +43,8 @@ func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
|||
}
|
||||
|
||||
var resp api.Response[any]
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||
t.Fatalf("unmarshal error: %v", result.Value)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
||||
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]
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||
t.Fatalf("unmarshal error: %v", result.Value)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != "unauthorised" {
|
||||
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]
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
if result := core.JSONUnmarshal(w.Body.Bytes(), &resp); !result.OK {
|
||||
t.Fatalf("unmarshal error: %v", result.Value)
|
||||
}
|
||||
if resp.Data != "classified" {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
func TestEngine_GroupsIter(t *testing.T) {
|
||||
e, _ := api.New()
|
||||
g1 := &healthGroup{}
|
||||
e.Register(g1)
|
||||
type streamGroupStub struct {
|
||||
healthGroup
|
||||
channels []string
|
||||
}
|
||||
|
||||
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
|
||||
for g := range e.GroupsIter() {
|
||||
groups = append(groups, g)
|
||||
for group := range engine.GroupsIter() {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
|
||||
if len(groups) != 1 {
|
||||
|
|
@ -28,45 +35,42 @@ func TestEngine_GroupsIter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEngine_GroupsIter_Good_SnapshotsCurrentGroups(t *testing.T) {
|
||||
e, _ := api.New()
|
||||
g1 := &healthGroup{}
|
||||
g2 := &stubGroup{}
|
||||
e.Register(g1)
|
||||
|
||||
iter := e.GroupsIter()
|
||||
e.Register(g2)
|
||||
|
||||
func TestModernization_GroupsIter_Bad(t *testing.T) {
|
||||
engine, _ := api.New()
|
||||
// No groups registered — iterator should yield nothing.
|
||||
var groups []api.RouteGroup
|
||||
for g := range iter {
|
||||
groups = append(groups, g)
|
||||
for group := range engine.GroupsIter() {
|
||||
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() != "health-extra" {
|
||||
t.Fatalf("expected snapshot to preserve original group, got %q", groups[0].Name())
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected 0 groups with no registration, got %d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
type streamGroupStub struct {
|
||||
healthGroup
|
||||
channels []string
|
||||
func TestModernization_GroupsIter_Ugly(t *testing.T) {
|
||||
defer func() {
|
||||
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) {
|
||||
e, _ := api.New()
|
||||
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
|
||||
g2 := &streamGroupStub{channels: []string{"ch3"}}
|
||||
e.Register(g1)
|
||||
e.Register(g2)
|
||||
func TestModernization_ChannelsIter_Good(t *testing.T) {
|
||||
engine, _ := api.New()
|
||||
engine.Register(&streamGroupStub{channels: []string{"ch1", "ch2"}})
|
||||
engine.Register(&streamGroupStub{channels: []string{"ch3"}})
|
||||
|
||||
var channels []string
|
||||
for ch := range e.ChannelsIter() {
|
||||
channels = append(channels, ch)
|
||||
for channelName := range engine.ChannelsIter() {
|
||||
channels = append(channels, channelName)
|
||||
}
|
||||
|
||||
expected := []string{"ch1", "ch2", "ch3"}
|
||||
|
|
@ -75,270 +79,134 @@ func TestEngine_ChannelsIter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEngine_ChannelsIter_Good_SnapshotsCurrentChannels(t *testing.T) {
|
||||
e, _ := api.New()
|
||||
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
|
||||
g2 := &streamGroupStub{channels: []string{"ch3"}}
|
||||
e.Register(g1)
|
||||
|
||||
iter := e.ChannelsIter()
|
||||
e.Register(g2)
|
||||
func TestModernization_ChannelsIter_Bad(t *testing.T) {
|
||||
engine, _ := api.New()
|
||||
// Register a group that has no Channels() — ChannelsIter must skip it.
|
||||
engine.Register(&healthGroup{})
|
||||
|
||||
var channels []string
|
||||
for ch := range iter {
|
||||
channels = append(channels, ch)
|
||||
for channelName := range engine.ChannelsIter() {
|
||||
channels = append(channels, channelName)
|
||||
}
|
||||
|
||||
expected := []string{"ch1", "ch2"}
|
||||
if !slices.Equal(channels, expected) {
|
||||
t.Fatalf("expected snapshot channels %v, got %v", expected, channels)
|
||||
if len(channels) != 0 {
|
||||
t.Fatalf("expected 0 channels for non-StreamGroup, got %v", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_CacheConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
||||
e, _ := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024))
|
||||
func TestModernization_ChannelsIter_Ugly(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ChannelsIter panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
cfg := e.CacheConfig()
|
||||
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("expected cache config to be enabled")
|
||||
}
|
||||
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)
|
||||
engine, _ := api.New()
|
||||
// Group with empty channel list must not panic during iteration.
|
||||
engine.Register(&streamGroupStub{channels: []string{}})
|
||||
for range engine.ChannelsIter() {
|
||||
t.Fatal("expected no iterations for empty channel list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// ── ToolBridge iterators ──────────────────────────────────────────────
|
||||
|
||||
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
|
||||
for t := range b.ToolsIter() {
|
||||
tools = append(tools, t)
|
||||
for tool := range bridge.ToolsIter() {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
if len(tools) != 1 || tools[0].Name != "test" {
|
||||
t.Errorf("ToolsIter failed, got %v", tools)
|
||||
}
|
||||
|
||||
// Test DescribeIter
|
||||
var descs []api.RouteDescription
|
||||
for d := range b.DescribeIter() {
|
||||
descs = append(descs, d)
|
||||
for desc := range bridge.DescribeIter() {
|
||||
descs = append(descs, desc)
|
||||
}
|
||||
if len(descs) != 1 || descs[0].Path != "/test" {
|
||||
t.Errorf("DescribeIter failed, got %v", descs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolBridge_Iterators_Good_SnapshotCurrentTools(t *testing.T) {
|
||||
b := api.NewToolBridge("/tools")
|
||||
b.Add(api.ToolDescriptor{Name: "first", Group: "g1"}, nil)
|
||||
|
||||
toolsIter := b.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)
|
||||
func TestModernization_ToolBridgeIterators_Bad(t *testing.T) {
|
||||
bridge := api.NewToolBridge("/tools")
|
||||
// Empty bridge — iterators must yield nothing.
|
||||
for range bridge.ToolsIter() {
|
||||
t.Fatal("expected no iterations on empty bridge (ToolsIter)")
|
||||
}
|
||||
|
||||
var descs []api.RouteDescription
|
||||
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)
|
||||
for range bridge.DescribeIter() {
|
||||
t.Fatal("expected no iterations on empty bridge (DescribeIter)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
for l := range api.SupportedLanguagesIter() {
|
||||
langs = append(langs, l)
|
||||
for language := range api.SupportedLanguagesIter() {
|
||||
langs = append(langs, language)
|
||||
}
|
||||
|
||||
if !slices.Contains(langs, "go") {
|
||||
t.Errorf("SupportedLanguagesIter missing 'go'")
|
||||
}
|
||||
|
||||
// Should be sorted
|
||||
if !slices.IsSorted(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"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
|
|
@ -27,17 +26,9 @@ import (
|
|||
)
|
||||
|
||||
// Option configures an Engine during construction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithAddr(":8080"))
|
||||
type Option func(*Engine)
|
||||
|
||||
// WithAddr sets the listen address for the server.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithAddr(":8443"))
|
||||
func WithAddr(addr string) Option {
|
||||
return func(e *Engine) {
|
||||
e.addr = addr
|
||||
|
|
@ -45,81 +36,46 @@ func WithAddr(addr string) Option {
|
|||
}
|
||||
|
||||
// WithBearerAuth adds bearer token authentication middleware.
|
||||
// Requests to /health and the Swagger UI path are exempt.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithBearerAuth("secret"))
|
||||
// Requests to /health and paths starting with /swagger are exempt.
|
||||
func WithBearerAuth(token string) Option {
|
||||
return func(e *Engine) {
|
||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
|
||||
skip := []string{"/health"}
|
||||
if swaggerPath := resolveSwaggerPath(e.swaggerPath); swaggerPath != "" {
|
||||
skip = append(skip, swaggerPath)
|
||||
}
|
||||
return skip
|
||||
}))
|
||||
skip := []string{"/health", "/swagger"}
|
||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
|
||||
}
|
||||
}
|
||||
|
||||
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
|
||||
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithRequestID())
|
||||
func WithRequestID() Option {
|
||||
return func(e *Engine) {
|
||||
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.
|
||||
// Pass "*" to allow all origins, or supply specific origin URLs.
|
||||
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
||||
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithCORS("*"))
|
||||
func WithCORS(allowOrigins ...string) Option {
|
||||
return func(e *Engine) {
|
||||
cfg := cors.Config{
|
||||
corsConfig := cors.Config{
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
}
|
||||
|
||||
if slices.Contains(allowOrigins, "*") {
|
||||
cfg.AllowAllOrigins = true
|
||||
corsConfig.AllowAllOrigins = true
|
||||
}
|
||||
if !cfg.AllowAllOrigins {
|
||||
cfg.AllowOrigins = allowOrigins
|
||||
if !corsConfig.AllowAllOrigins {
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithMiddleware(loggingMiddleware))
|
||||
func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
||||
return func(e *Engine) {
|
||||
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.
|
||||
// Directory listing is disabled; only individual files are served.
|
||||
// Internally this uses gin-contrib/static as Gin middleware.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithStatic("/assets", "./public"))
|
||||
func WithStatic(urlPrefix, root string) Option {
|
||||
return func(e *Engine) {
|
||||
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.
|
||||
// Use WithWSPath to customise the route before mounting the 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 {
|
||||
return func(e *Engine) {
|
||||
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
|
||||
// identity from X-authentik-* headers set by a trusted reverse proxy.
|
||||
// The middleware is permissive: unauthenticated requests are allowed through.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
|
||||
func WithAuthentik(cfg AuthentikConfig) Option {
|
||||
return func(e *Engine) {
|
||||
snapshot := cloneAuthentikConfig(cfg)
|
||||
e.authentikConfig = snapshot
|
||||
e.middlewares = append(e.middlewares, authentikMiddleware(snapshot, func() []string {
|
||||
return []string{resolveSwaggerPath(e.swaggerPath)}
|
||||
}))
|
||||
e.middlewares = append(e.middlewares, authentikMiddleware(cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSunset adds deprecation headers to every response.
|
||||
// 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.
|
||||
// WithSwagger enables the Swagger UI at /swagger/.
|
||||
// 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 {
|
||||
return func(e *Engine) {
|
||||
e.swaggerTitle = strings.TrimSpace(title)
|
||||
e.swaggerDesc = strings.TrimSpace(description)
|
||||
e.swaggerVersion = strings.TrimSpace(version)
|
||||
e.swaggerTitle = title
|
||||
e.swaggerDesc = description
|
||||
e.swaggerVersion = version
|
||||
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/.
|
||||
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
|
||||
// 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
|
||||
// enabled in development or behind authentication in production.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithPprof())
|
||||
func WithPprof() Option {
|
||||
return func(e *Engine) {
|
||||
e.pprofEnabled = true
|
||||
|
|
@ -374,10 +140,6 @@ func WithPprof() Option {
|
|||
// WARNING: expvar exposes runtime internals (memory allocation,
|
||||
// goroutine counts, command-line arguments) and should only be
|
||||
// enabled in development or behind authentication in production.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithExpvar())
|
||||
func WithExpvar() Option {
|
||||
return func(e *Engine) {
|
||||
e.expvarEnabled = true
|
||||
|
|
@ -389,10 +151,6 @@ func WithExpvar() Option {
|
|||
// 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
|
||||
// that terminates TLS.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithSecure())
|
||||
func WithSecure() Option {
|
||||
return func(e *Engine) {
|
||||
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.
|
||||
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
|
||||
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithGzip())
|
||||
func WithGzip(level ...int) Option {
|
||||
return func(e *Engine) {
|
||||
l := gzip.DefaultCompression
|
||||
|
|
@ -426,10 +180,6 @@ func WithGzip(level ...int) Option {
|
|||
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
|
||||
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
|
||||
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithBrotli())
|
||||
func WithBrotli(level ...int) Option {
|
||||
return func(e *Engine) {
|
||||
l := BrotliDefaultCompression
|
||||
|
|
@ -443,10 +193,6 @@ func WithBrotli(level ...int) Option {
|
|||
// WithSlog adds structured request logging middleware via gin-contrib/slog.
|
||||
// Each request is logged with method, path, status code, latency, and client IP.
|
||||
// If logger is nil, slog.Default() is used.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithSlog(nil))
|
||||
func WithSlog(logger *slog.Logger) Option {
|
||||
return func(e *Engine) {
|
||||
if logger == nil {
|
||||
|
|
@ -468,15 +214,8 @@ func WithSlog(logger *slog.Logger) Option {
|
|||
//
|
||||
// A zero or negative duration effectively disables the timeout (the handler
|
||||
// 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 {
|
||||
return func(e *Engine) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
e.middlewares = append(e.middlewares, timeout.New(
|
||||
timeout.WithTimeout(d),
|
||||
timeout.WithResponse(timeoutResponse),
|
||||
|
|
@ -493,77 +232,17 @@ func timeoutResponse(c *gin.Context) {
|
|||
// Successful (2xx) GET responses are cached for the given TTL and served
|
||||
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
|
||||
// and error responses pass through uncached.
|
||||
//
|
||||
// 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 {
|
||||
func WithCache(ttl time.Duration) Option {
|
||||
return func(e *Engine) {
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
e.cacheTTL = ttl
|
||||
e.cacheMaxEntries = maxEntries
|
||||
e.cacheMaxBytes = maxBytes
|
||||
store := newCacheStore(maxEntries, maxBytes)
|
||||
store := newCacheStore()
|
||||
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
|
||||
// gin-contrib/sessions using a cookie-based store. The name parameter
|
||||
// sets the session cookie name (e.g. "session") and secret is the key
|
||||
// used for cookie signing and encryption.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithSessions("session", []byte("secret")))
|
||||
func WithSessions(name string, secret []byte) Option {
|
||||
return func(e *Engine) {
|
||||
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
|
||||
// subject from HTTP Basic Authentication, evaluates it against the request
|
||||
// method and path, and returns 403 Forbidden when the policy denies access.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithAuthz(enforcer))
|
||||
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||
return func(e *Engine) {
|
||||
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
|
||||
// 401 Unauthorised or 400 Bad Request.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithHTTPSign(secrets))
|
||||
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||
return func(e *Engine) {
|
||||
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.
|
||||
// By default the endpoint is mounted at GET /events; use WithSSEPath to
|
||||
// customise the route. Clients receive a streaming text/event-stream
|
||||
// response and the broker manages client connections and broadcasts events
|
||||
// WithSSE registers a Server-Sent Events broker at GET /events.
|
||||
// Clients connect to the endpoint and receive a streaming text/event-stream
|
||||
// response. The broker manages client connections and broadcasts events
|
||||
// published via its Publish method.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// broker := api.NewSSEBroker()
|
||||
// engine, _ := api.New(api.WithSSE(broker))
|
||||
func WithSSE(broker *SSEBroker) Option {
|
||||
return func(e *Engine) {
|
||||
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
|
||||
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
|
||||
// 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
|
||||
// a *url.URL with the detected scheme, host, and base path.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithLocation())
|
||||
func WithLocation() Option {
|
||||
return func(e *Engine) {
|
||||
e.middlewares = append(e.middlewares, location.Default())
|
||||
|
|
@ -662,19 +311,15 @@ func WithLocation() Option {
|
|||
// api.New(
|
||||
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
||||
// )
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithGraphQL(schema))
|
||||
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
|
||||
return func(e *Engine) {
|
||||
cfg := &graphqlConfig{
|
||||
graphqlCfg := &graphqlConfig{
|
||||
schema: schema,
|
||||
path: defaultGraphQLPath,
|
||||
}
|
||||
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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
|
||||
package provider
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
coreapi "dappco.re/go/core/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
|
@ -40,20 +39,14 @@ type ProxyConfig struct {
|
|||
type ProxyProvider struct {
|
||||
config ProxyConfig
|
||||
proxy *httputil.ReverseProxy
|
||||
err error
|
||||
}
|
||||
|
||||
// NewProxy creates a ProxyProvider from the given configuration.
|
||||
// Invalid upstream URLs do not panic; the provider retains the
|
||||
// configuration error and responds with a standard 500 envelope when
|
||||
// mounted. This keeps provider construction safe for callers.
|
||||
// The upstream URL must be valid or NewProxy will panic.
|
||||
func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||
target, err := url.Parse(cfg.Upstream)
|
||||
if err != nil {
|
||||
return &ProxyProvider{
|
||||
config: cfg,
|
||||
err: err,
|
||||
}
|
||||
panic("provider.NewProxy: invalid upstream URL: " + err.Error())
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
|
|
@ -66,10 +59,11 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
|||
proxy.Director = func(req *http.Request) {
|
||||
defaultDirector(req)
|
||||
// Strip the base path prefix from the request path.
|
||||
req.URL.Path = stripBasePath(req.URL.Path, basePath)
|
||||
if req.URL.RawPath != "" {
|
||||
req.URL.RawPath = stripBasePath(req.URL.RawPath, basePath)
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, basePath)
|
||||
if req.URL.Path == "" {
|
||||
req.URL.Path = "/"
|
||||
}
|
||||
req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, basePath)
|
||||
}
|
||||
|
||||
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.
|
||||
func (p *ProxyProvider) Name() string {
|
||||
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.
|
||||
func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
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
|
||||
// responseWriter wrapper does not implement http.CloseNotifier,
|
||||
// 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) {
|
||||
p := provider.NewProxy(provider.ProxyConfig{
|
||||
Name: "bad",
|
||||
BasePath: "/api/v1/bad",
|
||||
Upstream: "://not-a-url",
|
||||
assert.Panics(t, func() {
|
||||
provider.NewProxy(provider.ProxyConfig{
|
||||
Name: "bad",
|
||||
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
|
||||
|
||||
|
|
@ -88,24 +88,6 @@ func (r *Registry) Streamable() []Streamable {
|
|||
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.
|
||||
func (r *Registry) Describable() []Describable {
|
||||
r.mu.RLock()
|
||||
|
|
@ -119,24 +101,6 @@ func (r *Registry) Describable() []Describable {
|
|||
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.
|
||||
func (r *Registry) Renderable() []Renderable {
|
||||
r.mu.RLock()
|
||||
|
|
@ -150,32 +114,12 @@ func (r *Registry) Renderable() []Renderable {
|
|||
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.
|
||||
type ProviderInfo struct {
|
||||
Name string `json:"name"`
|
||||
BasePath string `json:"basePath"`
|
||||
Channels []string `json:"channels,omitempty"`
|
||||
Element *ElementSpec `json:"element,omitempty"`
|
||||
SpecFile string `json:"specFile,omitempty"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
}
|
||||
|
||||
// Info returns a summary of all registered providers.
|
||||
|
|
@ -196,76 +140,7 @@ func (r *Registry) Info() []ProviderInfo {
|
|||
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()
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
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"}
|
||||
}
|
||||
|
||||
type specFileProvider struct {
|
||||
stubProvider
|
||||
specFile string
|
||||
}
|
||||
|
||||
func (s *specFileProvider) SpecFile() string { return s.specFile }
|
||||
|
||||
type fullProvider struct {
|
||||
streamableProvider
|
||||
}
|
||||
|
|
@ -119,39 +112,9 @@ func TestRegistry_Streamable_Good(t *testing.T) {
|
|||
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) {
|
||||
reg := provider.NewRegistry()
|
||||
reg.Add(&stubProvider{}) // not describable
|
||||
reg.Add(&stubProvider{}) // not describable
|
||||
reg.Add(&describableProvider{}) // describable
|
||||
|
||||
d := reg.Describable()
|
||||
|
|
@ -159,36 +122,6 @@ func TestRegistry_Describable_Good(t *testing.T) {
|
|||
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) {
|
||||
reg := provider.NewRegistry()
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
reg := provider.NewRegistry()
|
||||
reg.Add(&fullProvider{})
|
||||
|
|
@ -244,59 +147,6 @@ func TestRegistry_Info_Good(t *testing.T) {
|
|||
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) {
|
||||
reg := provider.NewRegistry()
|
||||
reg.Add(&stubProvider{})
|
||||
|
|
@ -308,27 +158,3 @@ func TestRegistry_Iter_Good(t *testing.T) {
|
|||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// 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 {
|
||||
Success bool `json:"success"`
|
||||
Data T `json:"data,omitempty"`
|
||||
|
|
@ -18,10 +11,6 @@ type Response[T any] struct {
|
|||
}
|
||||
|
||||
// Error describes a failed API request.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := api.Error{Code: "invalid_input", Message: "Name is required"}
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
|
|
@ -29,10 +18,6 @@ type Error struct {
|
|||
}
|
||||
|
||||
// Meta carries pagination and request metadata.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// meta := api.Meta{RequestID: "req_123", Duration: "12ms"}
|
||||
type Meta struct {
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
|
|
@ -43,9 +28,8 @@ type Meta struct {
|
|||
|
||||
// OK wraps data in a successful response envelope.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c.JSON(http.StatusOK, api.OK(map[string]any{"name": "status"}))
|
||||
// c.JSON(http.StatusOK, api.OK(user))
|
||||
// c.JSON(http.StatusOK, api.OK("healthy"))
|
||||
func OK[T any](data T) Response[T] {
|
||||
return Response[T]{
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Name is required"))
|
||||
// c.AbortWithStatusJSON(http.StatusUnauthorized, api.Fail("unauthorised", "token expired"))
|
||||
func Fail(code, message string) Response[any] {
|
||||
return Response[any]{
|
||||
Success: false,
|
||||
|
|
@ -70,9 +52,7 @@ func Fail(code, message string) Response[any] {
|
|||
|
||||
// FailWithDetails creates an error response with additional detail payload.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c.JSON(http.StatusBadRequest, api.FailWithDetails("invalid_input", "Name is required", map[string]any{"field": "name"}))
|
||||
// c.JSON(http.StatusBadRequest, api.FailWithDetails("validation", "invalid input", fieldErrors))
|
||||
func FailWithDetails(code, message string, details any) Response[any] {
|
||||
return Response[any]{
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c.JSON(http.StatusOK, api.Paginated(items, 2, 50, 200))
|
||||
// c.JSON(http.StatusOK, api.Paginated(users, page, 20, totalCount))
|
||||
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
||||
return Response[T]{
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
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\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Documentation\Attributes\ApiParameter;
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||
use Core\Mod\Mcp\Models\McpToolCall;
|
||||
|
|
@ -51,29 +50,7 @@ class McpApiController extends Controller
|
|||
* Get server details with tools and resources.
|
||||
*
|
||||
* 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
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
|
|
@ -82,17 +59,6 @@ class McpApiController extends Controller
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -104,15 +70,6 @@ class McpApiController extends Controller
|
|||
* Query params:
|
||||
* - 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
|
||||
{
|
||||
$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.
|
||||
*
|
||||
|
|
@ -361,8 +208,7 @@ class McpApiController extends Controller
|
|||
$result = $this->executeToolViaArtisan(
|
||||
$validated['server'],
|
||||
$validated['tool'],
|
||||
$validated['arguments'] ?? [],
|
||||
$toolVersion?->version
|
||||
$validated['arguments'] ?? []
|
||||
);
|
||||
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
|
@ -567,8 +413,6 @@ class McpApiController extends Controller
|
|||
*/
|
||||
public function resource(Request $request, string $uri): JsonResponse
|
||||
{
|
||||
$uri = rawurldecode($uri);
|
||||
|
||||
// Parse URI format: server://resource/path
|
||||
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||
return $this->validationErrorResponse([
|
||||
|
|
@ -584,35 +428,12 @@ class McpApiController extends Controller
|
|||
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 {
|
||||
$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([
|
||||
'uri' => $uri,
|
||||
'server' => $serverId,
|
||||
'resource' => $resourcePath,
|
||||
'content' => $content,
|
||||
'content' => $result,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->errorResponse(
|
||||
|
|
@ -629,14 +450,32 @@ class McpApiController extends Controller
|
|||
/**
|
||||
* 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) {
|
||||
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
|
||||
$process = proc_open(
|
||||
|
|
@ -672,157 +511,14 @@ class McpApiController extends Controller
|
|||
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.
|
||||
*/
|
||||
protected function readResourceViaArtisan(string $server, string $path): mixed
|
||||
{
|
||||
$command = $this->resolveMcpServerCommand($server);
|
||||
if (! $command) {
|
||||
throw new \RuntimeException("Unknown server: {$server}");
|
||||
}
|
||||
|
||||
$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;
|
||||
// Similar to executeToolViaArtisan but with resources/read method
|
||||
// Simplified for now - can expand later
|
||||
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,19 +27,6 @@ use Attribute;
|
|||
* {
|
||||
* 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)]
|
||||
readonly class ApiResponse
|
||||
|
|
@ -50,8 +37,6 @@ readonly class ApiResponse
|
|||
* @param string|null $description Description of the response
|
||||
* @param bool $paginated Whether this is a paginated collection response
|
||||
* @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 int $status,
|
||||
|
|
@ -59,8 +44,6 @@ readonly class ApiResponse
|
|||
public ?string $description = null,
|
||||
public bool $paginated = false,
|
||||
public array $headers = [],
|
||||
public ?string $contentType = null,
|
||||
public ?array $schema = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -81,11 +64,10 @@ readonly class ApiResponse
|
|||
302 => 'Found (redirect)',
|
||||
304 => 'Not modified',
|
||||
400 => 'Bad request',
|
||||
401 => 'Unauthorised',
|
||||
401 => 'Unauthorized',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not found',
|
||||
405 => 'Method not allowed',
|
||||
410 => 'Gone',
|
||||
409 => 'Conflict',
|
||||
422 => 'Validation error',
|
||||
429 => 'Too many requests',
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ class DocumentationController
|
|||
return match ($defaultUi) {
|
||||
'swagger' => $this->swagger($request),
|
||||
'redoc' => $this->redoc($request),
|
||||
'stoplight' => $this->stoplight($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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class ApiKeyAuthExtension implements Extension
|
|||
'properties' => [
|
||||
'message' => [
|
||||
'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\Extensions\ApiKeyAuthExtension;
|
||||
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 Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Routing\Route;
|
||||
|
|
@ -59,9 +57,7 @@ class OpenApiBuilder
|
|||
{
|
||||
$this->extensions = [
|
||||
new WorkspaceHeaderExtension,
|
||||
new VersionExtension,
|
||||
new RateLimitExtension,
|
||||
new SunsetExtension,
|
||||
new ApiKeyAuthExtension,
|
||||
];
|
||||
}
|
||||
|
|
@ -233,7 +229,6 @@ class OpenApiBuilder
|
|||
protected function buildPaths(array $config): array
|
||||
{
|
||||
$paths = [];
|
||||
$operationIds = [];
|
||||
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
||||
$excludePatterns = $config['routes']['exclude'] ?? [];
|
||||
|
||||
|
|
@ -248,7 +243,7 @@ class OpenApiBuilder
|
|||
|
||||
foreach ($methods as $method) {
|
||||
$method = strtolower($method);
|
||||
$operation = $this->buildOperation($route, $method, $config, $operationIds);
|
||||
$operation = $this->buildOperation($route, $method, $config);
|
||||
|
||||
if ($operation !== null) {
|
||||
$paths[$path][$method] = $operation;
|
||||
|
|
@ -302,7 +297,7 @@ class OpenApiBuilder
|
|||
/**
|
||||
* 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();
|
||||
$action = $route->getActionMethod();
|
||||
|
|
@ -314,7 +309,7 @@ class OpenApiBuilder
|
|||
|
||||
$operation = [
|
||||
'summary' => $this->buildSummary($route, $method),
|
||||
'operationId' => $this->buildOperationId($route, $method, $operationIds),
|
||||
'operationId' => $this->buildOperationId($route, $method),
|
||||
'tags' => $this->buildOperationTags($route, $controller, $action),
|
||||
'responses' => $this->buildResponses($controller, $action),
|
||||
];
|
||||
|
|
@ -333,7 +328,7 @@ class OpenApiBuilder
|
|||
|
||||
// Add request body for 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
|
||||
|
|
@ -403,24 +398,15 @@ class OpenApiBuilder
|
|||
/**
|
||||
* 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();
|
||||
|
||||
if ($name) {
|
||||
$base = Str::camel(str_replace(['.', '-'], '_', $name));
|
||||
} else {
|
||||
$base = Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||
return Str::camel(str_replace(['.', '-'], '_', $name));
|
||||
}
|
||||
|
||||
$count = $operationIds[$base] ?? 0;
|
||||
$operationIds[$base] = $count + 1;
|
||||
|
||||
if ($count === 0) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base.'_'.($count + 1);
|
||||
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -525,36 +511,16 @@ class OpenApiBuilder
|
|||
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
|
||||
{
|
||||
$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
|
||||
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
||||
foreach ($matches[1] as $param) {
|
||||
$addParameter([
|
||||
$parameters[] = [
|
||||
'name' => $param,
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string'],
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
// Add parameters from ApiParameter attributes
|
||||
|
|
@ -566,12 +532,12 @@ class OpenApiBuilder
|
|||
|
||||
foreach ($paramAttrs as $attr) {
|
||||
$param = $attr->newInstance();
|
||||
$addParameter($param->toOpenApi());
|
||||
$parameters[] = $param->toOpenApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($parameters);
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -612,23 +578,15 @@ class OpenApiBuilder
|
|||
'description' => $response->getDescription(),
|
||||
];
|
||||
|
||||
$schema = null;
|
||||
|
||||
if (is_array($response->schema) && ! empty($response->schema)) {
|
||||
$schema = $response->schema;
|
||||
} elseif ($response->resource !== null && class_exists($response->resource)) {
|
||||
if ($response->resource !== null && class_exists($response->resource)) {
|
||||
$schema = $this->extractResourceSchema($response->resource);
|
||||
|
||||
if ($response->paginated) {
|
||||
$schema = $this->wrapPaginatedSchema($schema);
|
||||
}
|
||||
}
|
||||
|
||||
if ($schema !== null) {
|
||||
$contentType = $response->contentType ?: 'application/json';
|
||||
|
||||
$result['content'] = [
|
||||
$contentType => [
|
||||
'application/json' => [
|
||||
'schema' => $schema,
|
||||
],
|
||||
];
|
||||
|
|
@ -656,181 +614,14 @@ class OpenApiBuilder
|
|||
return ['type' => 'object'];
|
||||
}
|
||||
|
||||
try {
|
||||
$resource = new $resourceClass(new \stdClass);
|
||||
$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.
|
||||
}
|
||||
|
||||
// For now, return a generic object schema
|
||||
// A more sophisticated implementation would analyze the resource's toArray method
|
||||
return [
|
||||
'type' => 'object',
|
||||
'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.
|
||||
*/
|
||||
|
|
@ -870,45 +661,8 @@ class OpenApiBuilder
|
|||
/**
|
||||
* 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 [
|
||||
'required' => true,
|
||||
'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('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
||||
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
||||
Route::get('/stoplight', [DocumentationController::class, 'stoplight'])->name('api.docs.stoplight');
|
||||
|
||||
// OpenAPI specification routes
|
||||
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_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\Concerns\HasApiResponses;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
|
|
@ -37,9 +36,9 @@ class RateLimitExceededException extends HttpException
|
|||
/**
|
||||
* 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',
|
||||
message: $this->getMessage(),
|
||||
meta: [
|
||||
|
|
@ -49,14 +48,6 @@ class RateLimitExceededException extends HttpException
|
|||
],
|
||||
status: 429,
|
||||
)->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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class ErrorResource extends JsonResource
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@
|
|||
|
||||
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\Middleware\PublicApiCors;
|
||||
use Core\Mcp\Middleware\McpApiKeyAuth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -18,81 +13,11 @@ use Illuminate\Support\Facades\Route;
|
|||
|
|
||||
| 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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -109,8 +34,6 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
|
|||
->name('servers.show');
|
||||
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
||||
->name('servers.tools');
|
||||
Route::get('/servers/{id}/resources', [McpApiController::class, 'resources'])
|
||||
->name('servers.resources');
|
||||
|
||||
// Tool version history (read)
|
||||
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Api\Services;
|
||||
namespace Mod\Api\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Api\Models\ApiUsage;
|
||||
use Core\Api\Models\ApiUsageDaily;
|
||||
use Mod\Api\Models\ApiUsage;
|
||||
use Mod\Api\Models\ApiUsageDaily;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Api\Models\ApiUsage;
|
||||
use Core\Api\Models\ApiUsageDaily;
|
||||
use Core\Api\Services\ApiUsageService;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Models\ApiUsage;
|
||||
use Mod\Api\Models\ApiUsageDaily;
|
||||
use Mod\Api\Services\ApiUsageService;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
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\Extensions\ApiKeyAuthExtension;
|
||||
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
||||
use Core\Api\Documentation\Extensions\SunsetExtension;
|
||||
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||
use Core\Api\Documentation\OpenApiBuilder;
|
||||
use Core\Api\RateLimit\RateLimit;
|
||||
|
|
@ -153,26 +152,6 @@ describe('OpenApiBuilder Controller Scanning', function () {
|
|||
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 () {
|
||||
$builder = new OpenApiBuilder;
|
||||
$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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -454,10 +323,9 @@ describe('ApiResponse Attribute Rendering', function () {
|
|||
201 => 'Resource created',
|
||||
204 => 'No content',
|
||||
400 => 'Bad request',
|
||||
401 => 'Unauthorised',
|
||||
401 => 'Unauthorized',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not found',
|
||||
410 => 'Gone',
|
||||
422 => 'Validation error',
|
||||
429 => 'Too many requests',
|
||||
500 => 'Internal server error',
|
||||
|
|
@ -479,29 +347,6 @@ describe('ApiResponse Attribute Rendering', function () {
|
|||
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 () {
|
||||
$response = new ApiResponse(
|
||||
status: 200,
|
||||
|
|
@ -804,7 +649,7 @@ describe('Extension System', function () {
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Error Response Documentation', function () {
|
||||
it('documents 401 Unauthorised response', function () {
|
||||
it('documents 401 Unauthorized response', function () {
|
||||
$extension = new ApiKeyAuthExtension;
|
||||
$spec = [
|
||||
'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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1245,16 +1027,6 @@ class TestPartialHiddenController
|
|||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -57,10 +57,6 @@ class OpenApiDocumentationTest extends TestCase
|
|||
$response = new ApiResponse(404);
|
||||
|
||||
$this->assertEquals('Not found', $response->getDescription());
|
||||
|
||||
$goneResponse = new ApiResponse(410);
|
||||
|
||||
$this->assertEquals('Gone', $goneResponse->getDescription());
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue