Compare commits
253 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194e7f61df | ||
|
|
aea902ed28 | ||
|
|
8dd15251ea | ||
|
|
a3a1c20e7a | ||
|
|
3896896090 | ||
|
|
0ec5f20bf5 | ||
|
|
8b5e572d1c | ||
|
|
76acb4534b | ||
|
|
1491e16f9e | ||
|
|
0022931eff | ||
|
|
2b71c78c33 | ||
|
|
be43aa3d72 | ||
|
|
2d09cc5d28 | ||
|
|
5971951c87 | ||
|
|
d7290c55ec | ||
|
|
8301d4d1c7 | ||
|
|
579b27d84e | ||
|
|
eb771875e2 | ||
|
|
0dc9695b91 | ||
|
|
0a299b79c1 | ||
|
|
a6693e1656 | ||
|
|
a07896d88e | ||
|
|
bfef7237cc | ||
|
|
f6add24177 | ||
|
|
f234fcba5f | ||
|
|
eb18611dc1 | ||
|
|
0171f9ad49 | ||
|
|
ec268c8100 | ||
|
|
ef51d9b1c3 | ||
|
|
ede71e2b1f | ||
|
|
655faa1c31 | ||
|
|
3c2f5512a8 | ||
|
|
814c1b6233 | ||
|
|
5c067b3dae | ||
|
|
f919e8a3be | ||
|
|
5de64a0a75 | ||
|
|
f760ab6c72 | ||
|
|
592cdd302e | ||
|
|
c4743a527e | ||
|
|
78d16a75cc | ||
|
|
71c179018d | ||
|
|
c383d85923 | ||
|
|
4fc93612e4 | ||
|
|
57ff0d2a48 | ||
|
|
d40ff2c294 | ||
|
|
192f8331f2 | ||
|
|
83d12d6024 | ||
|
|
51b176c1cf | ||
|
|
4725b39049 | ||
|
|
5e4cf1fde8 | ||
|
|
172a98f73a | ||
|
|
152645489b | ||
|
|
2fb2c6939f | ||
|
|
d06f4957a3 | ||
|
|
d225fd3178 | ||
|
|
be7616d437 | ||
|
|
e6f2d1286b | ||
|
|
8d92ee29d4 | ||
|
|
8149b0abf2 | ||
|
|
ed5822058d | ||
|
|
ec945970ee | ||
|
|
08cb1385d3 | ||
|
|
bbee19204f | ||
|
|
87a973a83e | ||
|
|
bc6a9ea0a7 | ||
|
|
22d600e7a7 | ||
|
|
b0549dc14e | ||
|
|
69dd16cba6 | ||
|
|
fb7702df67 | ||
|
|
8e1a424fc8 | ||
|
|
920c227e21 | ||
|
|
b99e445436 | ||
|
|
30e610686b | ||
|
|
8a23545a67 | ||
|
|
3b75dc1701 | ||
|
|
dd834211d8 | ||
|
|
b8fd020bb2 | ||
|
|
428552e58c | ||
|
|
824fc2cd75 | ||
|
|
fe256147e6 | ||
|
|
9b24a46fd5 | ||
|
|
29f4c23977 | ||
|
|
d7ef3610f7 | ||
|
|
76aa4c9974 | ||
|
|
5d28b8d83d | ||
|
|
a469a78b2a | ||
|
|
e47b010194 | ||
|
|
d9ccd7c49a | ||
|
|
c3143a5029 | ||
|
|
e8d54797bf | ||
|
|
85d6f6dd6e | ||
|
|
f53617c507 | ||
|
|
13f901b88f | ||
|
|
6ea0b26a13 | ||
|
|
8e28b0209c | ||
|
|
f0b2d8b248 | ||
|
|
006a065ea0 | ||
|
|
273bc3d70a | ||
|
|
41615bbe47 | ||
|
|
d803ac8f3b | ||
|
|
1fb55c9515 | ||
|
|
ef641c7547 | ||
|
|
39bf094b51 | ||
|
|
b4d414b702 | ||
|
|
085c57a06d | ||
|
|
86c2150a21 | ||
|
|
02082db8f4 | ||
|
|
50b6a9197f | ||
|
|
08a2d93776 | ||
|
|
6b075a207b | ||
|
|
e23d8e9780 | ||
|
|
c21c3409d7 | ||
|
|
812400f303 | ||
|
|
0bb07f43f0 | ||
|
|
68f5abefd0 | ||
|
|
ffbb6d83d0 | ||
|
|
2c87fa02cb | ||
|
|
68bf8dcaf8 | ||
|
|
9b5477c051 | ||
|
|
ad751fc974 | ||
|
|
68edd770d8 | ||
|
|
f67e3fe5de | ||
|
|
1f43f019b1 | ||
|
|
799de22d4d | ||
|
|
159f8d3b9f | ||
|
|
93bef3ed85 | ||
|
|
0f20eaa7b8 | ||
|
|
9449c195c3 | ||
|
|
0984c2f48a | ||
|
|
47e8c8a795 | ||
|
|
14eedd7f91 | ||
|
|
eb7e1e51cb | ||
|
|
06f2263b73 | ||
|
|
29324b0a0b | ||
|
|
b64c8d3271 | ||
|
|
dd74a80b1e | ||
|
|
cebad9b77b | ||
|
|
ccfbe57faf | ||
|
|
00c20ea6e8 | ||
|
|
9553808595 | ||
|
|
929b6b97ca | ||
|
|
a89a70851f | ||
|
|
ec7391cb06 | ||
|
|
f0d25392a8 | ||
|
|
1a8fafeec5 | ||
|
|
2cfa970993 | ||
|
|
2bdcb55980 | ||
|
|
cba25cf9fc | ||
|
|
93cdb62dfe | ||
|
|
691ef936d4 | ||
|
|
b2116cc896 | ||
|
|
2fd17a432c | ||
|
|
475027d716 | ||
|
|
2d1ed133f2 | ||
|
|
867221cbb8 | ||
|
|
e0bdca7889 | ||
|
|
4ce697189a | ||
|
|
db9daadbce | ||
|
|
f62933f570 | ||
|
|
b0adb53dec | ||
|
|
cb726000a9 | ||
|
|
f2f262a4c2 | ||
|
|
eceda4e5c1 | ||
|
|
7e4d8eb179 | ||
|
|
bb7d88f3ce | ||
|
|
4d7f3a9f99 | ||
|
|
071de51bb5 | ||
|
|
a589d3bac6 | ||
|
|
d45ee6598e | ||
|
|
b2d3c96ed7 | ||
|
|
0ed1cfa1b1 | ||
|
|
d3737974ce | ||
|
|
b341b4b860 | ||
|
|
e2935ce79e | ||
|
|
c9627729b5 | ||
|
|
cd4e24d15f | ||
|
|
6017ac7132 | ||
|
|
6034579c00 | ||
|
|
408a709a43 | ||
|
|
ebad4c397d | ||
|
|
7c3e8e7ba6 | ||
|
|
13cc93f4f4 | ||
|
|
3f010b855e | ||
|
|
ea94081231 | ||
|
|
a055781d5d | ||
|
|
0ed72c4952 | ||
|
|
862604dc22 | ||
|
|
0144244ccd | ||
|
|
69beb451b5 | ||
|
|
3b26a15048 | ||
|
|
edb1cf0c1e | ||
|
|
2f8f8f805e | ||
|
|
5da281c431 | ||
|
|
6e878778dc | ||
|
|
cdef85dcc9 | ||
|
|
ee83aabca0 | ||
|
|
db787a799b | ||
|
|
aff54403c6 | ||
|
|
164a1d4f0e | ||
|
|
f6349145bc | ||
|
|
1ec5bf4062 | ||
|
|
c48effb6b7 | ||
|
|
5b59a1dd10 | ||
|
|
da9bb918f7 | ||
|
|
c6034031a3 | ||
|
|
bfa80e3a27 | ||
|
|
b9f91811d8 | ||
|
|
19838779ef | ||
|
|
1cc0f2fd48 | ||
|
|
ac59d284b1 | ||
|
|
3b92eda93a | ||
|
|
90600aa434 | ||
|
|
e713fb9f56 | ||
|
|
28f9540fa8 | ||
|
|
ac21992623 | ||
|
|
4420651fcf | ||
|
|
1bb2f68b3f | ||
|
|
fd09309ce9 | ||
|
|
726938f04a | ||
|
|
321ced1a36 | ||
|
|
4bc132f101 | ||
|
|
b58de8f8f0 | ||
|
|
90e237ae31 | ||
|
|
684a37cd84 | ||
|
|
926a723d9c | ||
|
|
fb6812df09 | ||
|
|
c4cbd018ac | ||
|
|
37b7fd21ae | ||
|
|
00a59947b4 | ||
|
|
4efa435a47 | ||
|
|
2d8bb12311 | ||
|
|
825b61c113 | ||
|
|
c9cf407530 | ||
|
|
5d5ca8aa51 | ||
|
|
9aa7c644ef | ||
|
|
65ae0fca6d | ||
|
|
837a910148 | ||
|
|
9afaed4d7c | ||
|
|
f030665566 | ||
|
|
16abc45efa | ||
|
|
491c9a1c69 | ||
|
|
5eaaa8a01e | ||
|
|
71eebc53aa | ||
|
|
1c9e4891e7 | ||
|
|
6fdd769212 | ||
|
|
797c5f9571 | ||
|
|
db1efd502c | ||
|
|
3ead3fed2b | ||
|
|
6ef194754e | ||
|
|
10fc9559fa | ||
|
|
6fc1767d31 | ||
|
|
ee3fba1e7a | ||
|
|
ca9b495884 |
138 changed files with 19749 additions and 625 deletions
150
api.go
150
api.go
|
|
@ -9,6 +9,7 @@ import (
|
|||
"errors"
|
||||
"iter"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
|
|
@ -24,23 +25,57 @@ 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
|
||||
wsHandler http.Handler
|
||||
sseBroker *SSEBroker
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerDesc string
|
||||
swaggerVersion string
|
||||
pprofEnabled bool
|
||||
expvarEnabled bool
|
||||
graphql *graphqlConfig
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// }
|
||||
func New(opts ...Option) (*Engine, error) {
|
||||
e := &Engine{
|
||||
addr: defaultAddr,
|
||||
|
|
@ -52,27 +87,54 @@ func New(opts ...Option) (*Engine, error) {
|
|||
}
|
||||
|
||||
// Addr returns the configured listen address.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithAddr(":9090"))
|
||||
// addr := engine.Addr()
|
||||
func (e *Engine) Addr() string {
|
||||
return e.addr
|
||||
}
|
||||
|
||||
// Groups returns all registered route groups.
|
||||
// Groups returns a copy of all registered route groups.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// groups := engine.Groups()
|
||||
func (e *Engine) Groups() []RouteGroup {
|
||||
return e.groups
|
||||
return slices.Clone(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] {
|
||||
return slices.Values(e.groups)
|
||||
groups := slices.Clone(e.groups)
|
||||
return slices.Values(groups)
|
||||
}
|
||||
|
||||
// Register adds a route group to the engine.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine.Register(myGroup)
|
||||
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 {
|
||||
|
|
@ -84,9 +146,16 @@ func (e *Engine) Channels() []string {
|
|||
}
|
||||
|
||||
// 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 e.groups {
|
||||
for _, g := range groups {
|
||||
if sg, ok := g.(StreamGroup); ok {
|
||||
for _, c := range sg.Channels() {
|
||||
if !yield(c) {
|
||||
|
|
@ -100,12 +169,22 @@ 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()
|
||||
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{
|
||||
Addr: e.addr,
|
||||
|
|
@ -120,8 +199,18 @@ func (e *Engine) Serve(ctx context.Context) error {
|
|||
close(errCh)
|
||||
}()
|
||||
|
||||
// Block until context is cancelled.
|
||||
<-ctx.Done()
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Graceful shutdown with timeout.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
|
|
@ -139,7 +228,7 @@ func (e *Engine) Serve(ctx context.Context) error {
|
|||
// user-supplied middleware, the health endpoint, and all registered route groups.
|
||||
func (e *Engine) build() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(recoveryMiddleware())
|
||||
|
||||
// Apply user-supplied middleware after recovery but before routes.
|
||||
for _, mw := range e.middlewares {
|
||||
|
|
@ -153,18 +242,21 @@ func (e *Engine) build() *gin.Engine {
|
|||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Mount WebSocket handler if configured.
|
||||
if e.wsHandler != nil {
|
||||
r.GET("/ws", wrapWSHandler(e.wsHandler))
|
||||
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
|
||||
}
|
||||
|
||||
// Mount SSE endpoint if configured.
|
||||
if e.sseBroker != nil {
|
||||
r.GET("/events", e.sseBroker.Handler())
|
||||
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
|
||||
}
|
||||
|
||||
// Mount GraphQL endpoint if configured.
|
||||
|
|
@ -174,7 +266,7 @@ func (e *Engine) build() *gin.Engine {
|
|||
|
||||
// Mount Swagger UI if enabled.
|
||||
if e.swaggerEnabled {
|
||||
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
|
||||
registerSwagger(r, e, e.groups)
|
||||
}
|
||||
|
||||
// Mount pprof profiling endpoints if enabled.
|
||||
|
|
@ -189,3 +281,17 @@ func (e *Engine) build() *gin.Engine {
|
|||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
api_test.go
98
api_test.go
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────
|
||||
|
|
@ -29,6 +29,16 @@ 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) {
|
||||
|
|
@ -85,6 +95,28 @@ 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) {
|
||||
|
|
@ -149,6 +181,41 @@ 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) {
|
||||
|
|
@ -202,3 +269,32 @@ func TestServe_Good_GracefulShutdown(t *testing.T) {
|
|||
t.Fatal("Serve did not return within 5 seconds after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
authentik.go
98
authentik.go
|
|
@ -14,6 +14,10 @@ import (
|
|||
)
|
||||
|
||||
// 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
|
||||
|
|
@ -26,12 +30,32 @@ type AuthentikConfig struct {
|
|||
TrustedProxy bool
|
||||
|
||||
// PublicPaths lists additional paths that do not require authentication.
|
||||
// /health and /swagger are always public.
|
||||
// /health and the configured Swagger UI path 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"`
|
||||
|
|
@ -43,6 +67,10 @@ type AuthentikUser struct {
|
|||
}
|
||||
|
||||
// HasGroup reports whether the user belongs to the named group.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// user.HasGroup("admins")
|
||||
func (u *AuthentikUser) HasGroup(group string) bool {
|
||||
return slices.Contains(u.Groups, group)
|
||||
}
|
||||
|
|
@ -53,6 +81,10 @@ const authentikUserKey = "authentik_user"
|
|||
// GetUser retrieves the AuthentikUser from the Gin context.
|
||||
// Returns nil when no user has been set (unauthenticated request or
|
||||
// middleware not active).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// user := api.GetUser(c)
|
||||
func GetUser(c *gin.Context) *AuthentikUser {
|
||||
val, exists := c.Get(authentikUserKey)
|
||||
if !exists {
|
||||
|
|
@ -134,7 +166,7 @@ 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) gin.HandlerFunc {
|
||||
func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.HandlerFunc {
|
||||
// Build the set of public paths that skip header extraction entirely.
|
||||
public := map[string]bool{
|
||||
"/health": true,
|
||||
|
|
@ -148,11 +180,19 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
|
|||
// Skip public paths.
|
||||
path := c.Request.URL.Path
|
||||
for p := range public {
|
||||
if strings.HasPrefix(path, p) {
|
||||
if isPublicPath(path, p) {
|
||||
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 {
|
||||
|
|
@ -193,9 +233,57 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if GetUser(c) == nil {
|
||||
|
|
@ -210,6 +298,10 @@ func RequireAuth() gin.HandlerFunc {
|
|||
// RequireGroup is Gin middleware that rejects requests from users who do
|
||||
// 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)
|
||||
func RequireGroup(group string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := GetUser(c)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── AuthentikUser ──────────────────────────────────────────────────────
|
||||
|
|
@ -221,6 +221,27 @@ 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)
|
||||
|
||||
|
|
@ -322,6 +343,33 @@ 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) {
|
||||
|
|
@ -458,3 +506,15 @@ func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|||
c.JSON(200, api.OK("admin panel"))
|
||||
})
|
||||
}
|
||||
|
||||
// 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 (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"))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/casbin/casbin/v2/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// casbinModel is a minimal RESTful ACL model for testing authorisation.
|
||||
|
|
|
|||
831
bridge.go
831
bridge.go
|
|
@ -3,12 +3,30 @@
|
|||
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
|
||||
|
|
@ -19,6 +37,10 @@ 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
|
||||
|
|
@ -30,7 +52,14 @@ 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")
|
||||
func NewToolBridge(basePath string) *ToolBridge {
|
||||
return &ToolBridge{
|
||||
basePath: basePath,
|
||||
|
|
@ -39,17 +68,39 @@ 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)
|
||||
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)
|
||||
|
|
@ -57,44 +108,31 @@ func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
// Describe returns OpenAPI route descriptions for all registered tools.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// descs := bridge.Describe()
|
||||
func (b *ToolBridge) Describe() []RouteDescription {
|
||||
descs := make([]RouteDescription, 0, len(b.tools))
|
||||
for _, t := range b.tools {
|
||||
tags := []string{t.descriptor.Group}
|
||||
if t.descriptor.Group == "" {
|
||||
tags = []string{b.name}
|
||||
}
|
||||
descs = append(descs, RouteDescription{
|
||||
Method: "POST",
|
||||
Path: "/" + t.descriptor.Name,
|
||||
Summary: t.descriptor.Description,
|
||||
Description: t.descriptor.Description,
|
||||
Tags: tags,
|
||||
RequestBody: t.descriptor.InputSchema,
|
||||
Response: t.descriptor.OutputSchema,
|
||||
})
|
||||
tools := b.snapshotTools()
|
||||
descs := make([]RouteDescription, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
descs = append(descs, describeTool(tool.descriptor, b.name))
|
||||
}
|
||||
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 _, t := range b.tools {
|
||||
tags := []string{t.descriptor.Group}
|
||||
if t.descriptor.Group == "" {
|
||||
tags = []string{b.name}
|
||||
}
|
||||
rd := RouteDescription{
|
||||
Method: "POST",
|
||||
Path: "/" + t.descriptor.Name,
|
||||
Summary: t.descriptor.Description,
|
||||
Description: t.descriptor.Description,
|
||||
Tags: tags,
|
||||
RequestBody: t.descriptor.InputSchema,
|
||||
Response: t.descriptor.OutputSchema,
|
||||
}
|
||||
if !yield(rd) {
|
||||
for _, tool := range tools {
|
||||
if !yield(describeTool(tool.descriptor, b.name)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -102,21 +140,746 @@ func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
|||
}
|
||||
|
||||
// Tools returns all registered tool descriptors.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// descs := bridge.Tools()
|
||||
func (b *ToolBridge) Tools() []ToolDescriptor {
|
||||
descs := make([]ToolDescriptor, len(b.tools))
|
||||
for i, t := range b.tools {
|
||||
tools := b.snapshotTools()
|
||||
descs := make([]ToolDescriptor, len(tools))
|
||||
for i, t := range tools {
|
||||
descs[i] = t.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 _, t := range b.tools {
|
||||
if !yield(t.descriptor) {
|
||||
for _, tool := range 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
519
bridge_test.go
519
bridge_test.go
|
|
@ -3,6 +3,7 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -10,7 +11,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── ToolBridge ─────────────────────────────────────────────────────────
|
||||
|
|
@ -153,6 +154,522 @@ 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) {})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── WithBrotli ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
149
cache.go
149
cache.go
|
|
@ -4,8 +4,10 @@ package api
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -17,34 +19,57 @@ 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
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
order *list.List
|
||||
index map[string]*list.Element
|
||||
maxEntries int
|
||||
maxBytes int
|
||||
currentBytes int
|
||||
}
|
||||
|
||||
// newCacheStore creates an empty cache store.
|
||||
func newCacheStore() *cacheStore {
|
||||
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
|
||||
return &cacheStore{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
entries: make(map[string]*cacheEntry),
|
||||
order: list.New(),
|
||||
index: make(map[string]*list.Element),
|
||||
maxEntries: maxEntries,
|
||||
maxBytes: maxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// get retrieves a non-expired entry for the given key.
|
||||
// Returns nil if the key is missing or expired.
|
||||
func (s *cacheStore) get(key string) *cacheEntry {
|
||||
s.mu.RLock()
|
||||
s.mu.Lock()
|
||||
entry, ok := s.entries[key]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
if elem, exists := s.index[key]; exists {
|
||||
s.order.MoveToFront(elem)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
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
|
||||
|
|
@ -55,10 +80,81 @@ 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
|
||||
|
|
@ -89,14 +185,31 @@ 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
|
||||
}
|
||||
if http.CanonicalHeaderKey(k) == "Content-Length" {
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
c.Writer.Header().Set(k, v)
|
||||
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(entry.body)
|
||||
_, _ = c.Writer.Write(body)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
|
@ -119,8 +232,28 @@ 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
|
||||
}
|
||||
|
|
|
|||
43
cache_config.go
Normal file
43
cache_config.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// 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
|
||||
}
|
||||
304
cache_test.go
304
cache_test.go
|
|
@ -14,7 +14,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// cacheCounterGroup registers routes that increment a counter on each call,
|
||||
|
|
@ -40,6 +40,23 @@ 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) {
|
||||
|
|
@ -89,6 +106,36 @@ 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{}
|
||||
|
|
@ -214,6 +261,189 @@ 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{}
|
||||
|
|
@ -250,3 +480,75 @@ func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
|||
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCache_Good_EvictsWhenCapacityReached(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
grp := &cacheCounterGroup{}
|
||||
e, _ := api.New(api.WithCache(5*time.Second, 1))
|
||||
e.Register(grp)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
963
client_test.go
Normal file
963
client_test.go
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
// 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,7 +8,12 @@ func init() {
|
|||
cli.RegisterCommands(AddAPICommands)
|
||||
}
|
||||
|
||||
// AddAPICommands registers the 'api' command group.
|
||||
// AddAPICommands registers the `api` command group.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// root := &cli.Command{Use: "root"}
|
||||
// api.AddAPICommands(root)
|
||||
func AddAPICommands(root *cli.Command) {
|
||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||
root.AddCommand(apiCmd)
|
||||
|
|
|
|||
67
cmd/api/cmd_args.go
Normal file
67
cmd/api/cmd_args.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// 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,17 +3,23 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
goapi "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSDKTitle = "Lethean Core API"
|
||||
defaultSDKDescription = "Lethean Core API"
|
||||
defaultSDKVersion = "1.0.0"
|
||||
)
|
||||
|
||||
func addSDKCommand(parent *cli.Command) {
|
||||
|
|
@ -22,40 +28,20 @@ func addSDKCommand(parent *cli.Command) {
|
|||
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 {
|
||||
if lang == "" {
|
||||
return coreerr.E("sdk.Generate", "--lang is required. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
|
||||
}
|
||||
|
||||
// If no spec file provided, generate one to a temp file.
|
||||
if specFile == "" {
|
||||
builder := &goapi.SpecBuilder{
|
||||
Title: "Lethean Core API",
|
||||
Description: "Lethean Core API",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
bridge := goapi.NewToolBridge("/tools")
|
||||
groups := []goapi.RouteGroup{bridge}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
||||
if err != nil {
|
||||
return coreerr.E("sdk.Generate", "create temp spec file", err)
|
||||
}
|
||||
defer coreio.Local.Delete(tmpFile.Name())
|
||||
|
||||
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
|
||||
tmpFile.Close()
|
||||
return coreerr.E("sdk.Generate", "generate spec", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
specFile = tmpFile.Name()
|
||||
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)
|
||||
}
|
||||
|
||||
gen := &goapi.SDKGenerator{
|
||||
SpecPath: specFile,
|
||||
OutputDir: output,
|
||||
PackageName: packageName,
|
||||
}
|
||||
|
|
@ -67,14 +53,38 @@ func addSDKCommand(parent *cli.Command) {
|
|||
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
|
||||
}
|
||||
|
||||
// Generate for each language.
|
||||
for l := range strings.SplitSeq(lang, ",") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
continue
|
||||
// If no spec file was provided, generate one only after confirming the
|
||||
// generator is available.
|
||||
if specFile == "" {
|
||||
builder, err := sdkSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := sdkSpecGroupsIter()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
||||
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 {
|
||||
return coreerr.E("sdk.Generate", "generate spec", err)
|
||||
}
|
||||
specFile = tmpPath
|
||||
}
|
||||
|
||||
gen.SpecPath = specFile
|
||||
|
||||
// Generate for each language.
|
||||
for _, l := range languages {
|
||||
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
|
||||
if err := gen.Generate(context.Background(), l); err != nil {
|
||||
if err := gen.Generate(cli.Context(), l); err != nil {
|
||||
return coreerr.E("sdk.Generate", "generate "+l, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
||||
|
|
@ -85,8 +95,50 @@ 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 existing OpenAPI spec (generates from MCP tools if not provided)")
|
||||
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, &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,52 +3,103 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
goapi "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
func addSpecCommand(parent *cli.Command) {
|
||||
var (
|
||||
output string
|
||||
format string
|
||||
title string
|
||||
version string
|
||||
output string
|
||||
format string
|
||||
cfg specBuilderConfig
|
||||
)
|
||||
|
||||
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 registered route groups.
|
||||
// Additional groups can be added here as the platform grows.
|
||||
builder := &goapi.SpecBuilder{
|
||||
Title: title,
|
||||
Description: "Lethean Core API",
|
||||
Version: version,
|
||||
// Build spec from all route groups registered for CLI generation.
|
||||
builder, err := newSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 := []goapi.RouteGroup{bridge}
|
||||
groups := specGroupsIter(bridge)
|
||||
|
||||
if output != "" {
|
||||
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
|
||||
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
return goapi.ExportSpec(os.Stdout, format, builder, groups)
|
||||
return goapi.ExportSpecIter(os.Stdout, 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")
|
||||
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")
|
||||
registerSpecBuilderFlags(cmd, &cfg)
|
||||
|
||||
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
124
cmd/api/spec_builder.go
Normal file
124
cmd/api/spec_builder.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// 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
|
||||
}
|
||||
16
cmd/api/spec_groups_iter.go
Normal file
16
cmd/api/spec_groups_iter.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// 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)
|
||||
}
|
||||
59
codegen.go
59
codegen.go
|
|
@ -11,9 +11,10 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Supported SDK target languages.
|
||||
|
|
@ -32,6 +33,10 @@ var supportedLanguages = map[string]string{
|
|||
}
|
||||
|
||||
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// gen := &api.SDKGenerator{SpecPath: "./openapi.yaml", OutputDir: "./sdk", PackageName: "service"}
|
||||
type SDKGenerator struct {
|
||||
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
||||
SpecPath string
|
||||
|
|
@ -45,17 +50,45 @@ type SDKGenerator struct {
|
|||
|
||||
// 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")
|
||||
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)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(g.SpecPath); os.IsNotExist(err) {
|
||||
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+g.SpecPath, 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)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(g.OutputDir, language)
|
||||
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)
|
||||
if err := coreio.Local.EnsureDir(outputDir); err != nil {
|
||||
return coreerr.E("SDKGenerator.Generate", "create output directory", err)
|
||||
}
|
||||
|
|
@ -87,6 +120,12 @@ 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")
|
||||
// }
|
||||
func (g *SDKGenerator) Available() bool {
|
||||
_, err := exec.LookPath("openapi-generator-cli")
|
||||
return err == nil
|
||||
|
|
@ -94,11 +133,21 @@ func (g *SDKGenerator) Available() bool {
|
|||
|
||||
// SupportedLanguages returns the list of supported SDK target languages
|
||||
// in sorted order for deterministic output.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// langs := api.SupportedLanguages()
|
||||
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)
|
||||
// }
|
||||
func SupportedLanguagesIter() iter.Seq[string] {
|
||||
return slices.Values(SupportedLanguages())
|
||||
}
|
||||
|
|
|
|||
107
codegen_test.go
107
codegen_test.go
|
|
@ -10,7 +10,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── SDKGenerator tests ─────────────────────────────────────────────────────
|
||||
|
|
@ -59,7 +59,108 @@ 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")
|
||||
|
|
@ -73,8 +174,8 @@ func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
|||
OutputDir: outputDir,
|
||||
}
|
||||
|
||||
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
|
||||
// but the output directory should have been created before that.
|
||||
// Generate will fail at the exec step, but the output directory should have
|
||||
// been created before the CLI returned its non-zero status.
|
||||
_ = gen.Generate(context.Background(), "go")
|
||||
|
||||
expected := filepath.Join(outputDir, "go")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ type Engine struct {
|
|||
swaggerTitle string
|
||||
swaggerDesc string
|
||||
swaggerVersion string
|
||||
swaggerExternalDocsDescription string
|
||||
swaggerExternalDocsURL string
|
||||
pprofEnabled bool
|
||||
expvarEnabled bool
|
||||
graphql *graphqlConfig
|
||||
|
|
@ -128,6 +130,9 @@ type RouteDescription struct {
|
|||
Summary string
|
||||
Description string
|
||||
Tags []string
|
||||
Deprecated bool
|
||||
StatusCode int
|
||||
Parameters []ParameterDescription
|
||||
RequestBody map[string]any
|
||||
Response map[string]any
|
||||
}
|
||||
|
|
@ -151,12 +156,19 @@ 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 |
|
||||
|
|
@ -164,7 +176,8 @@ 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 | `X-Cache: HIT` header on cache hits; 2xx only |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -371,14 +384,19 @@ redirects and introspection). The GraphQL handler is created via gqlgen's
|
|||
|
||||
## 8. Response Caching
|
||||
|
||||
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests:
|
||||
`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))
|
||||
```
|
||||
|
||||
- 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.
|
||||
- There is no size limit on the cache.
|
||||
- `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.
|
||||
|
||||
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
|
||||
capture the response body and status code for storage.
|
||||
|
|
@ -573,7 +591,9 @@ 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`
|
||||
|
||||
|
|
@ -585,6 +605,10 @@ 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,11 +169,12 @@ At the end of Phase 3, the module has 176 tests.
|
|||
|
||||
## Known Limitations
|
||||
|
||||
### 1. Cache has no size limit
|
||||
### 1. Cache remains in-memory
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
### 2. SDK codegen requires an external binary
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ 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
|
||||
|
|
@ -94,7 +95,7 @@ engine.Register(&Routes{service: svc})
|
|||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
||||
| `options.go` | All `With*()` option functions (25 options) |
|
||||
| `options.go` | All `With*()` option functions (28 options) |
|
||||
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
||||
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
||||
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
||||
|
|
|
|||
78
export.go
78
export.go
|
|
@ -4,55 +4,111 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
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)
|
||||
}
|
||||
|
||||
switch format {
|
||||
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)) {
|
||||
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("ExportSpec", "unmarshal spec", err)
|
||||
return coreerr.E(op, "unmarshal spec", err)
|
||||
}
|
||||
enc := yaml.NewEncoder(w)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(obj); err != nil {
|
||||
return coreerr.E("ExportSpec", "encode yaml", err)
|
||||
return coreerr.E(op, "encode yaml", err)
|
||||
}
|
||||
return enc.Close()
|
||||
default:
|
||||
return coreerr.E("ExportSpec", "unsupported format "+format+": use \"json\" or \"yaml\"", nil)
|
||||
return coreerr.E(op, fmt.Sprintf("unsupported format %s: use %q or %q", format, "json", "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())
|
||||
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("ExportSpecToFile", "create directory", err)
|
||||
return coreerr.E(op, "create directory", err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return coreerr.E("ExportSpecToFile", "create file", err)
|
||||
return coreerr.E(op, "create file", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return ExportSpec(f, format, builder, groups)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package api_test
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"iter"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -14,7 +15,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── ExportSpec tests ─────────────────────────────────────────────────────
|
||||
|
|
@ -65,6 +66,24 @@ 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"}
|
||||
|
||||
|
|
@ -164,3 +183,41 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
|
|||
t.Fatal("expected /tools/metrics_query path in spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportSpecIter_Good_WithGroupIterator(t *testing.T) {
|
||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Expvar runtime metrics endpoint ─────────────────────────────────
|
||||
|
|
|
|||
3
go-io/go.mod
Normal file
3
go-io/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/io
|
||||
|
||||
go 1.26.0
|
||||
29
go-io/local.go
Normal file
29
go-io/local.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// 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
|
||||
}
|
||||
14
go-log/error.go
Normal file
14
go-log/error.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// 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)
|
||||
}
|
||||
3
go-log/go.mod
Normal file
3
go-log/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/log
|
||||
|
||||
go 1.26.0
|
||||
22
go.mod
22
go.mod
|
|
@ -1,11 +1,11 @@
|
|||
module forge.lthn.ai/core/api
|
||||
module dappco.re/go/core/api
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/cli v0.3.7
|
||||
forge.lthn.ai/core/go-io v0.1.7
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
dappco.re/go/core/io v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
dappco.re/go/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,9 +38,10 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.3.3 // indirect
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.7 // indirect
|
||||
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
|
||||
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
|
||||
|
|
@ -127,3 +128,10 @@ require (
|
|||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -1,13 +1,11 @@
|
|||
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
||||
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||
|
|
|
|||
74
graphql.go
74
graphql.go
|
|
@ -4,6 +4,7 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
|
|
@ -21,10 +22,61 @@ 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
|
||||
|
|
@ -33,9 +85,13 @@ func WithPlayground() GraphQLOption {
|
|||
|
||||
// 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 = path
|
||||
cfg.path = normaliseGraphQLPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +111,22 @@ func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return func(c *gin.Context) {
|
||||
|
|
|
|||
45
graphql_config_test.go
Normal file
45
graphql_config_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/vektah/gqlparser/v2"
|
||||
"github.com/vektah/gqlparser/v2/ast"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// newTestSchema creates a minimal ExecutableSchema that responds to { name }
|
||||
|
|
@ -192,6 +192,72 @@ 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)
|
||||
|
||||
|
|
|
|||
102
group.go
102
group.go
|
|
@ -2,10 +2,18 @@
|
|||
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"iter"
|
||||
|
||||
"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
|
||||
|
|
@ -18,6 +26,10 @@ 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
|
||||
|
|
@ -26,19 +38,89 @@ 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
|
||||
}
|
||||
|
||||
// RouteDescription describes a single endpoint for OpenAPI generation.
|
||||
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
|
||||
RequestBody map[string]any // JSON Schema for request body (nil for GET)
|
||||
Response map[string]any // JSON Schema for success response data
|
||||
// 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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Stub implementations ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── WithGzip ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
"github.com/gin-contrib/httpsign/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
const testSecretKey = "test-secret-key-for-hmac-sha256"
|
||||
|
|
|
|||
139
i18n.go
139
i18n.go
|
|
@ -3,6 +3,9 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
|
@ -13,7 +16,21 @@ 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".
|
||||
|
|
@ -30,11 +47,32 @@ 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.
|
||||
|
|
@ -57,14 +95,16 @@ func WithI18n(cfg ...I18nConfig) Option {
|
|||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
snapshot := cloneI18nConfig(config)
|
||||
e.i18nConfig = snapshot
|
||||
matcher := language.NewMatcher(tags)
|
||||
|
||||
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config))
|
||||
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
|
||||
// it against supported locales, and stores the result in the context.
|
||||
// it against supported locales, and stores the resolved BCP 47 tag in the context.
|
||||
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
accept := c.GetHeader("Accept-Language")
|
||||
|
|
@ -75,19 +115,17 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
|||
} else {
|
||||
tags, _, _ := language.ParseAcceptLanguage(accept)
|
||||
tag, _, _ := matcher.Match(tags...)
|
||||
base, _ := tag.Base()
|
||||
locale = base.String()
|
||||
locale = tag.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +135,10 @@ 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 {
|
||||
|
|
@ -109,6 +151,10 @@ 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 {
|
||||
|
|
@ -117,5 +163,84 @@ 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
|
||||
}
|
||||
|
|
|
|||
149
i18n_test.go
149
i18n_test.go
|
|
@ -6,11 +6,12 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -133,6 +134,33 @@ 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(
|
||||
|
|
@ -224,3 +252,122 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
|
|||
t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithI18n_Good_FallsBackToParentLocaleMessage(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)
|
||||
|
||||
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{
|
||||
DefaultLocale: "en",
|
||||
Supported: []string{"en", "fr"},
|
||||
Messages: map[string]map[string]string{
|
||||
"en": {"greeting": "Hello"},
|
||||
"fr": {"greeting": "Bonjour"},
|
||||
},
|
||||
}))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/gin-contrib/location/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
113
middleware.go
113
middleware.go
|
|
@ -5,20 +5,44 @@ package api
|
|||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 []string) gin.HandlerFunc {
|
||||
func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check whether the request path should bypass authentication.
|
||||
for _, path := range skip {
|
||||
if strings.HasPrefix(c.Request.URL.Path, path) {
|
||||
for _, path := range skip() {
|
||||
if isPublicPath(c.Request.URL.Path, path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
|
@ -40,11 +64,37 @@ func bearerAuthMiddleware(token string, skip []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)
|
||||
|
|
@ -52,8 +102,63 @@ func requestIDMiddleware() gin.HandlerFunc {
|
|||
id = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
c.Set("request_id", id)
|
||||
c.Set(requestIDContextKey, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -26,6 +27,75 @@ 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) {
|
||||
|
|
@ -114,6 +184,21 @@ 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) {
|
||||
|
|
@ -151,6 +236,176 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ package api_test
|
|||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
func TestEngine_GroupsIter(t *testing.T) {
|
||||
|
|
@ -27,6 +28,28 @@ 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)
|
||||
|
||||
var groups []api.RouteGroup
|
||||
for g := range iter {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
type streamGroupStub struct {
|
||||
healthGroup
|
||||
channels []string
|
||||
|
|
@ -52,6 +75,207 @@ 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)
|
||||
|
||||
var channels []string
|
||||
for ch := range iter {
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
|
||||
expected := []string{"ch1", "ch2"}
|
||||
if !slices.Equal(channels, expected) {
|
||||
t.Fatalf("expected snapshot channels %v, got %v", expected, channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_CacheConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
|
||||
e, _ := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
cfg := e.RuntimeConfig()
|
||||
|
||||
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"}
|
||||
|
|
@ -76,6 +300,33 @@ func TestToolBridge_Iterators(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodegen_SupportedLanguagesIter(t *testing.T) {
|
||||
var langs []string
|
||||
for l := range api.SupportedLanguagesIter() {
|
||||
|
|
|
|||
2116
openapi.go
2116
openapi.go
File diff suppressed because it is too large
Load diff
2749
openapi_test.go
2749
openapi_test.go
File diff suppressed because it is too large
Load diff
389
options.go
389
options.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
|
|
@ -26,9 +27,17 @@ 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
|
||||
|
|
@ -36,26 +45,57 @@ func WithAddr(addr string) Option {
|
|||
}
|
||||
|
||||
// WithBearerAuth adds bearer token authentication middleware.
|
||||
// Requests to /health and paths starting with /swagger are exempt.
|
||||
// Requests to /health and the Swagger UI path are exempt.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithBearerAuth("secret"))
|
||||
func WithBearerAuth(token string) Option {
|
||||
return func(e *Engine) {
|
||||
skip := []string{"/health", "/swagger"}
|
||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
|
||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
|
||||
skip := []string{"/health"}
|
||||
if swaggerPath := resolveSwaggerPath(e.swaggerPath); swaggerPath != "" {
|
||||
skip = append(skip, swaggerPath)
|
||||
}
|
||||
return 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{
|
||||
|
|
@ -76,6 +116,10 @@ func WithCORS(allowOrigins ...string) Option {
|
|||
}
|
||||
|
||||
// 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...)
|
||||
|
|
@ -85,6 +129,10 @@ 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)))
|
||||
|
|
@ -92,33 +140,215 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func WithAuthentik(cfg AuthentikConfig) Option {
|
||||
// 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.middlewares = append(e.middlewares, authentikMiddleware(cfg))
|
||||
e.wsPath = normaliseWSPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSwagger enables the Swagger UI at /swagger/.
|
||||
// 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)}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 = title
|
||||
e.swaggerDesc = description
|
||||
e.swaggerVersion = version
|
||||
e.swaggerTitle = strings.TrimSpace(title)
|
||||
e.swaggerDesc = strings.TrimSpace(description)
|
||||
e.swaggerVersion = strings.TrimSpace(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
|
||||
|
|
@ -126,6 +356,10 @@ func WithSwagger(title, description, version 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
|
||||
|
|
@ -140,6 +374,10 @@ 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
|
||||
|
|
@ -151,6 +389,10 @@ 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{
|
||||
|
|
@ -167,6 +409,10 @@ 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
|
||||
|
|
@ -180,6 +426,10 @@ 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
|
||||
|
|
@ -193,6 +443,10 @@ 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 {
|
||||
|
|
@ -214,8 +468,15 @@ 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),
|
||||
|
|
@ -232,17 +493,77 @@ 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.
|
||||
func WithCache(ttl time.Duration) Option {
|
||||
//
|
||||
// Optional integer limits enable LRU eviction:
|
||||
// - maxEntries limits the number of cached responses
|
||||
// - maxBytes limits the approximate total cached payload size
|
||||
//
|
||||
// Pass a non-positive value to either limit to leave that dimension
|
||||
// unbounded for backward compatibility. A non-positive TTL disables the
|
||||
// middleware entirely.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithCache(5*time.Minute, 100, 10<<20))
|
||||
func WithCache(ttl time.Duration, maxEntries ...int) Option {
|
||||
entryLimit := 0
|
||||
byteLimit := 0
|
||||
if len(maxEntries) > 0 {
|
||||
entryLimit = maxEntries[0]
|
||||
}
|
||||
if len(maxEntries) > 1 {
|
||||
byteLimit = maxEntries[1]
|
||||
}
|
||||
return WithCacheLimits(ttl, entryLimit, byteLimit)
|
||||
}
|
||||
|
||||
// WithCacheLimits adds in-memory response caching middleware for GET requests
|
||||
// with explicit entry and payload-size bounds.
|
||||
//
|
||||
// This is the clearer form of WithCache when call sites want to make the
|
||||
// eviction policy self-documenting.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
|
||||
func WithCacheLimits(ttl time.Duration, maxEntries, maxBytes int) Option {
|
||||
return func(e *Engine) {
|
||||
store := newCacheStore()
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
e.cacheTTL = ttl
|
||||
e.cacheMaxEntries = maxEntries
|
||||
e.cacheMaxBytes = maxBytes
|
||||
store := newCacheStore(maxEntries, maxBytes)
|
||||
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)
|
||||
|
|
@ -255,6 +576,10 @@ 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))
|
||||
|
|
@ -274,6 +599,10 @@ 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...)
|
||||
|
|
@ -281,16 +610,34 @@ func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
|
@ -298,6 +645,10 @@ func WithSSE(broker *SSEBroker) 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())
|
||||
|
|
@ -311,6 +662,10 @@ 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{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package provider defines the Service Provider Framework interfaces.
|
||||
//
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
package provider
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/api"
|
||||
"dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// Provider extends RouteGroup with a provider identity.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// SPDX-Licence-Identifier: EUPL-1.2
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package provider
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
coreapi "dappco.re/go/core/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
|
@ -39,14 +40,20 @@ type ProxyConfig struct {
|
|||
type ProxyProvider struct {
|
||||
config ProxyConfig
|
||||
proxy *httputil.ReverseProxy
|
||||
err error
|
||||
}
|
||||
|
||||
// NewProxy creates a ProxyProvider from the given configuration.
|
||||
// The upstream URL must be valid or NewProxy will panic.
|
||||
// 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.
|
||||
func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||
target, err := url.Parse(cfg.Upstream)
|
||||
if err != nil {
|
||||
panic("provider.NewProxy: invalid upstream URL: " + err.Error())
|
||||
return &ProxyProvider{
|
||||
config: cfg,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
|
|
@ -59,11 +66,10 @@ 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 = strings.TrimPrefix(req.URL.Path, basePath)
|
||||
if req.URL.Path == "" {
|
||||
req.URL.Path = "/"
|
||||
req.URL.Path = stripBasePath(req.URL.Path, basePath)
|
||||
if req.URL.RawPath != "" {
|
||||
req.URL.RawPath = stripBasePath(req.URL.RawPath, basePath)
|
||||
}
|
||||
req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, basePath)
|
||||
}
|
||||
|
||||
return &ProxyProvider{
|
||||
|
|
@ -72,6 +78,43 @@ 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
|
||||
|
|
@ -85,6 +128,19 @@ 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.
|
||||
|
|
|
|||
26
pkg/provider/proxy_internal_test.go
Normal file
26
pkg/provider/proxy_internal_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/api"
|
||||
"forge.lthn.ai/core/api/pkg/provider"
|
||||
"dappco.re/go/core/api"
|
||||
"dappco.re/go/core/api/pkg/provider"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -183,11 +183,32 @@ func TestProxyProvider_Renderable_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
provider.NewProxy(provider.ProxyConfig{
|
||||
Name: "bad",
|
||||
BasePath: "/api/v1/bad",
|
||||
Upstream: "://not-a-url",
|
||||
})
|
||||
p := 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-Licence-Identifier: EUPL-1.2
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package provider
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"slices"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/api"
|
||||
"dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// Registry collects providers and mounts them on an api.Engine.
|
||||
|
|
@ -88,6 +88,24 @@ 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()
|
||||
|
|
@ -101,6 +119,24 @@ 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()
|
||||
|
|
@ -114,12 +150,32 @@ 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.
|
||||
|
|
@ -140,7 +196,76 @@ 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ package provider_test
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/api"
|
||||
"forge.lthn.ai/core/api/pkg/provider"
|
||||
"dappco.re/go/core/api"
|
||||
"dappco.re/go/core/api/pkg/provider"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -38,6 +38,13 @@ 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
|
||||
}
|
||||
|
|
@ -112,9 +119,39 @@ 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()
|
||||
|
|
@ -122,6 +159,36 @@ 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
|
||||
|
|
@ -132,6 +199,36 @@ 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{})
|
||||
|
|
@ -147,6 +244,59 @@ 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{})
|
||||
|
|
@ -158,3 +308,27 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Pprof profiling endpoints ─────────────────────────────────────────
|
||||
|
|
|
|||
216
ratelimit.go
Normal file
216
ratelimit.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// 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])
|
||||
}
|
||||
240
ratelimit_test.go
Normal file
240
ratelimit_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
59
response.go
59
response.go
|
|
@ -2,7 +2,14 @@
|
|||
|
||||
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"`
|
||||
|
|
@ -11,6 +18,10 @@ 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"`
|
||||
|
|
@ -18,6 +29,10 @@ 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"`
|
||||
|
|
@ -27,6 +42,10 @@ type Meta struct {
|
|||
}
|
||||
|
||||
// OK wraps data in a successful response envelope.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c.JSON(http.StatusOK, api.OK(map[string]any{"name": "status"}))
|
||||
func OK[T any](data T) Response[T] {
|
||||
return Response[T]{
|
||||
Success: true,
|
||||
|
|
@ -35,6 +54,10 @@ 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"))
|
||||
func Fail(code, message string) Response[any] {
|
||||
return Response[any]{
|
||||
Success: false,
|
||||
|
|
@ -46,6 +69,10 @@ 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"}))
|
||||
func FailWithDetails(code, message string, details any) Response[any] {
|
||||
return Response[any]{
|
||||
Success: false,
|
||||
|
|
@ -58,6 +85,10 @@ 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))
|
||||
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
||||
return Response[T]{
|
||||
Success: true,
|
||||
|
|
@ -69,3 +100,31 @@ 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
Normal file
277
response_meta.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
// 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")
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── OK ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
46
runtime_config.go
Normal file
46
runtime_config.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── WithSecure ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
53
servers.go
Normal file
53
servers.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/api"
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// ── WithSlog ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
283
spec_builder_helper.go
Normal file
283
spec_builder_helper.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
spec_builder_helper_internal_test.go
Normal file
29
spec_builder_helper_internal_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
647
spec_builder_helper_test.go
Normal file
647
spec_builder_helper_test.go
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
// 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
Normal file
154
spec_registry.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// 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()
|
||||
}
|
||||
138
spec_registry_test.go
Normal file
138
spec_registry_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,33 @@ use Illuminate\Http\JsonResponse;
|
|||
*/
|
||||
trait HasApiResponses
|
||||
{
|
||||
/**
|
||||
* Return a standard error response.
|
||||
*/
|
||||
protected function errorResponse(
|
||||
string $errorCode,
|
||||
string $message,
|
||||
array $meta = [],
|
||||
int $status = 400,
|
||||
): JsonResponse {
|
||||
return response()->json(array_merge([
|
||||
'success' => false,
|
||||
'error' => $errorCode,
|
||||
'message' => $message,
|
||||
'error_code' => $errorCode,
|
||||
], $meta), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a no workspace response.
|
||||
*/
|
||||
protected function noWorkspaceResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'no_workspace',
|
||||
'message' => 'No workspace found. Please select a workspace first.',
|
||||
], 404);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'no_workspace',
|
||||
message: 'No workspace found. Please select a workspace first.',
|
||||
status: 404,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,10 +45,14 @@ trait HasApiResponses
|
|||
*/
|
||||
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => "{$resource} not found.",
|
||||
], 404);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'not_found',
|
||||
message: "{$resource} not found.",
|
||||
meta: [
|
||||
'resource' => $resource,
|
||||
],
|
||||
status: 404,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,12 +60,15 @@ trait HasApiResponses
|
|||
*/
|
||||
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'feature_limit_reached',
|
||||
'message' => $message ?? 'You have reached your limit for this feature.',
|
||||
'feature' => $feature,
|
||||
'upgrade_url' => route('hub.usage'),
|
||||
], 403);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'feature_limit_reached',
|
||||
message: $message ?? 'You have reached your limit for this feature.',
|
||||
meta: [
|
||||
'feature' => $feature,
|
||||
'upgrade_url' => route('hub.usage'),
|
||||
],
|
||||
status: 403,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,10 +76,20 @@ trait HasApiResponses
|
|||
*/
|
||||
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'access_denied',
|
||||
'message' => $message,
|
||||
], 403);
|
||||
return $this->forbiddenResponse($message, status: 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a forbidden response.
|
||||
*/
|
||||
protected function forbiddenResponse(string $message, array $meta = [], int $status = 403): JsonResponse
|
||||
{
|
||||
return $this->errorResponse(
|
||||
errorCode: 'forbidden',
|
||||
message: $message,
|
||||
meta: $meta,
|
||||
status: $status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,6 +98,7 @@ trait HasApiResponses
|
|||
protected function successResponse(string $message, array $data = []): JsonResponse
|
||||
{
|
||||
return response()->json(array_merge([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
], $data));
|
||||
}
|
||||
|
|
@ -73,6 +109,7 @@ trait HasApiResponses
|
|||
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $resource,
|
||||
], 201);
|
||||
|
|
@ -81,13 +118,16 @@ trait HasApiResponses
|
|||
/**
|
||||
* Return a validation error response.
|
||||
*/
|
||||
protected function validationErrorResponse(array $errors): JsonResponse
|
||||
protected function validationErrorResponse(array $errors, int $status = 422): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'validation_failed',
|
||||
'message' => 'The given data was invalid.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'validation_failed',
|
||||
message: 'The given data was invalid.',
|
||||
meta: [
|
||||
'errors' => $errors,
|
||||
],
|
||||
status: $status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,10 +137,11 @@ trait HasApiResponses
|
|||
*/
|
||||
protected function invalidStatusResponse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'invalid_status',
|
||||
'message' => $message,
|
||||
], 422);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'invalid_status',
|
||||
message: $message,
|
||||
status: 422,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,15 +151,13 @@ trait HasApiResponses
|
|||
*/
|
||||
protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse
|
||||
{
|
||||
$response = [
|
||||
'error' => 'provider_error',
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($provider !== null) {
|
||||
$response['provider'] = $provider;
|
||||
}
|
||||
|
||||
return response()->json($response, 400);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'provider_error',
|
||||
message: $message,
|
||||
meta: array_filter([
|
||||
'provider' => $provider,
|
||||
]),
|
||||
status: 400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
src/php/src/Api/Controllers/Api/EntitlementApiController.php
Normal file
101
src/php/src/Api/Controllers/Api/EntitlementApiController.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
65
src/php/src/Api/Controllers/Api/SeoReportController.php
Normal file
65
src/php/src/Api/Controllers/Api/SeoReportController.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
src/php/src/Api/Controllers/Api/UnifiedPixelController.php
Normal file
64
src/php/src/Api/Controllers/Api/UnifiedPixelController.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Api\Controllers\Api;
|
||||
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
|
@ -16,6 +17,8 @@ use Core\Social\Models\Webhook;
|
|||
*/
|
||||
class WebhookSecretController extends Controller
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function __construct(
|
||||
protected WebhookSecretRotationService $rotationService
|
||||
) {}
|
||||
|
|
@ -28,7 +31,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
|
|
@ -36,7 +39,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
return $this->notFoundResponse('Webhook');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -66,7 +69,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
|
|
@ -74,7 +77,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
return $this->notFoundResponse('Webhook endpoint');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -104,7 +107,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
|
|
@ -112,7 +115,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
return $this->notFoundResponse('Webhook');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -128,7 +131,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
|
|
@ -136,7 +139,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
return $this->notFoundResponse('Webhook endpoint');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -152,7 +155,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
|
|
@ -160,7 +163,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
return $this->notFoundResponse('Webhook');
|
||||
}
|
||||
|
||||
$this->rotationService->invalidatePreviousSecret($webhook);
|
||||
|
|
@ -179,7 +182,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
|
|
@ -187,7 +190,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
return $this->notFoundResponse('Webhook endpoint');
|
||||
}
|
||||
|
||||
$this->rotationService->invalidatePreviousSecret($endpoint);
|
||||
|
|
@ -206,7 +209,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
|
|
@ -214,7 +217,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
return $this->notFoundResponse('Webhook');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -240,7 +243,7 @@ class WebhookSecretController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
|
|
@ -248,7 +251,7 @@ class WebhookSecretController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
return $this->notFoundResponse('Webhook endpoint');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Api\Controllers\Api;
|
||||
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
|
@ -17,6 +18,8 @@ use Core\Api\Services\WebhookTemplateService;
|
|||
*/
|
||||
class WebhookTemplateController extends Controller
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function __construct(
|
||||
protected WebhookTemplateService $templateService
|
||||
) {}
|
||||
|
|
@ -29,7 +32,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -61,7 +64,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -69,7 +72,7 @@ class WebhookTemplateController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
return $this->notFoundResponse('Template');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -85,7 +88,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -102,10 +105,9 @@ class WebhookTemplateController extends Controller
|
|||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json([
|
||||
'error' => 'Invalid template',
|
||||
'errors' => $validation['errors'],
|
||||
], 422);
|
||||
return $this->validationErrorResponse([
|
||||
'template' => $validation['errors'],
|
||||
]);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::create([
|
||||
|
|
@ -133,7 +135,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -141,7 +143,7 @@ class WebhookTemplateController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
return $this->notFoundResponse('Template');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -159,10 +161,9 @@ class WebhookTemplateController extends Controller
|
|||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json([
|
||||
'error' => 'Invalid template',
|
||||
'errors' => $validation['errors'],
|
||||
], 422);
|
||||
return $this->validationErrorResponse([
|
||||
'template' => $validation['errors'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +187,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -194,12 +195,12 @@ class WebhookTemplateController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
return $this->notFoundResponse('Template');
|
||||
}
|
||||
|
||||
// Don't allow deleting builtin templates
|
||||
if ($template->isBuiltin()) {
|
||||
return response()->json(['error' => 'Built-in templates cannot be deleted'], 403);
|
||||
return $this->forbiddenResponse('Built-in templates cannot be deleted');
|
||||
}
|
||||
|
||||
$template->delete();
|
||||
|
|
@ -255,7 +256,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -263,7 +264,7 @@ class WebhookTemplateController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
return $this->notFoundResponse('Template');
|
||||
}
|
||||
|
||||
$newName = $request->input('name', $template->name.' (copy)');
|
||||
|
|
@ -282,7 +283,7 @@ class WebhookTemplateController extends Controller
|
|||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
return $this->noWorkspaceResponse();
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
|
|
@ -290,7 +291,7 @@ class WebhookTemplateController extends Controller
|
|||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
return $this->notFoundResponse('Template');
|
||||
}
|
||||
|
||||
$template->setAsDefault();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
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;
|
||||
|
|
@ -23,6 +25,8 @@ use Symfony\Component\Yaml\Yaml;
|
|||
*/
|
||||
class McpApiController extends Controller
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
/**
|
||||
* List all available MCP servers.
|
||||
*
|
||||
|
|
@ -47,15 +51,48 @@ 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);
|
||||
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +104,21 @@ 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);
|
||||
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
return $this->notFoundResponse('Server');
|
||||
}
|
||||
|
||||
$tools = $server['tools'] ?? [];
|
||||
|
|
@ -107,6 +153,116 @@ 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.
|
||||
*
|
||||
|
|
@ -129,13 +285,13 @@ class McpApiController extends Controller
|
|||
|
||||
$server = $this->loadServerFull($validated['server']);
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
return $this->notFoundResponse('Server');
|
||||
}
|
||||
|
||||
// Verify tool exists in server definition
|
||||
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
||||
if (! $toolDef) {
|
||||
return response()->json(['error' => 'Tool not found'], 404);
|
||||
return $this->notFoundResponse('Tool');
|
||||
}
|
||||
|
||||
// Version resolution
|
||||
|
|
@ -153,16 +309,18 @@ class McpApiController extends Controller
|
|||
// Sunset versions return 410 Gone
|
||||
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $error['message'] ?? 'Version error',
|
||||
'error_code' => $error['code'] ?? 'VERSION_ERROR',
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'requested_version' => $validated['version'] ?? null,
|
||||
'latest_version' => $error['latest_version'] ?? null,
|
||||
'migration_notes' => $error['migration_notes'] ?? null,
|
||||
], $status);
|
||||
return $this->errorResponse(
|
||||
errorCode: $error['code'] ?? 'VERSION_ERROR',
|
||||
message: $error['message'] ?? 'Version error',
|
||||
meta: [
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'requested_version' => $validated['version'] ?? null,
|
||||
'latest_version' => $error['latest_version'] ?? null,
|
||||
'migration_notes' => $error['migration_notes'] ?? null,
|
||||
],
|
||||
status: $status,
|
||||
);
|
||||
}
|
||||
|
||||
/** @var McpToolVersion|null $toolVersion */
|
||||
|
|
@ -178,15 +336,17 @@ class McpApiController extends Controller
|
|||
);
|
||||
|
||||
if (! empty($validationErrors)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Validation failed',
|
||||
'error_code' => 'VALIDATION_ERROR',
|
||||
'validation_errors' => $validationErrors,
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? 'unversioned',
|
||||
], 422);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
meta: [
|
||||
'validation_errors' => $validationErrors,
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? 'unversioned',
|
||||
],
|
||||
status: 422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +361,8 @@ class McpApiController extends Controller
|
|||
$result = $this->executeToolViaArtisan(
|
||||
$validated['server'],
|
||||
$validated['tool'],
|
||||
$validated['arguments'] ?? []
|
||||
$validated['arguments'] ?? [],
|
||||
$toolVersion?->version
|
||||
);
|
||||
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
|
@ -262,7 +423,16 @@ class McpApiController extends Controller
|
|||
// Log full request for debugging/replay
|
||||
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
|
||||
|
||||
return response()->json($response, 500);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'tool_execution_error',
|
||||
message: $e->getMessage(),
|
||||
meta: array_filter([
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||
]),
|
||||
status: 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,13 +513,13 @@ class McpApiController extends Controller
|
|||
{
|
||||
$serverConfig = $this->loadServerFull($server);
|
||||
if (! $serverConfig) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
return $this->notFoundResponse('Server');
|
||||
}
|
||||
|
||||
// Verify tool exists in server definition
|
||||
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
|
||||
if (! $toolDef) {
|
||||
return response()->json(['error' => 'Tool not found'], 404);
|
||||
return $this->notFoundResponse('Tool');
|
||||
}
|
||||
|
||||
$versionService = app(ToolVersionService::class);
|
||||
|
|
@ -374,7 +544,7 @@ class McpApiController extends Controller
|
|||
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
|
||||
|
||||
if (! $toolVersion) {
|
||||
return response()->json(['error' => 'Version not found'], 404);
|
||||
return $this->notFoundResponse('Version');
|
||||
}
|
||||
|
||||
$response = response()->json($toolVersion->toApiArray());
|
||||
|
|
@ -397,9 +567,13 @@ 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 response()->json(['error' => 'Invalid resource URI format'], 400);
|
||||
return $this->validationErrorResponse([
|
||||
'uri' => ['Invalid resource URI format. Expected pattern server://resource/path'],
|
||||
], 400);
|
||||
}
|
||||
|
||||
$serverId = $matches[1];
|
||||
|
|
@ -407,53 +581,62 @@ class McpApiController extends Controller
|
|||
|
||||
$server = $this->loadServerFull($serverId);
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
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,
|
||||
'content' => $result,
|
||||
'server' => $serverId,
|
||||
'resource' => $resourcePath,
|
||||
'content' => $content,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage(),
|
||||
'uri' => $uri,
|
||||
], 500);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'resource_read_error',
|
||||
message: $e->getMessage(),
|
||||
meta: [
|
||||
'uri' => $uri,
|
||||
],
|
||||
status: 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tool via artisan MCP server command.
|
||||
*/
|
||||
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
|
||||
protected function executeToolViaArtisan(string $server, string $tool, array $arguments, ?string $version = null): mixed
|
||||
{
|
||||
$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;
|
||||
$command = $this->resolveMcpServerCommand($server);
|
||||
if (! $command) {
|
||||
throw new \RuntimeException("Unknown server: {$server}");
|
||||
}
|
||||
|
||||
// Build MCP request
|
||||
$mcpRequest = [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => uniqid(),
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $tool,
|
||||
'arguments' => $arguments,
|
||||
],
|
||||
];
|
||||
$mcpRequest = $this->buildToolCallRequest($tool, $arguments, $version);
|
||||
|
||||
// Execute via process
|
||||
$process = proc_open(
|
||||
|
|
@ -489,14 +672,157 @@ 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
|
||||
{
|
||||
// Similar to executeToolViaArtisan but with resources/read method
|
||||
// Simplified for now - can expand later
|
||||
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ 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
|
||||
|
|
@ -37,6 +50,8 @@ 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,
|
||||
|
|
@ -44,6 +59,8 @@ readonly class ApiResponse
|
|||
public ?string $description = null,
|
||||
public bool $paginated = false,
|
||||
public array $headers = [],
|
||||
public ?string $contentType = null,
|
||||
public ?array $schema = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -64,10 +81,11 @@ readonly class ApiResponse
|
|||
302 => 'Found (redirect)',
|
||||
304 => 'Not modified',
|
||||
400 => 'Bad request',
|
||||
401 => 'Unauthorized',
|
||||
401 => 'Unauthorised',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not found',
|
||||
405 => 'Method not allowed',
|
||||
410 => 'Gone',
|
||||
409 => 'Conflict',
|
||||
422 => 'Validation error',
|
||||
429 => 'Too many requests',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class DocumentationController
|
|||
return match ($defaultUi) {
|
||||
'swagger' => $this->swagger($request),
|
||||
'redoc' => $this->redoc($request),
|
||||
'stoplight' => $this->stoplight($request),
|
||||
default => $this->scalar($request),
|
||||
};
|
||||
}
|
||||
|
|
@ -74,6 +75,19 @@ 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 unauthorized.',
|
||||
'example' => 'This action is unauthorised.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
147
src/php/src/Api/Documentation/Extensions/SunsetExtension.php
Normal file
147
src/php/src/Api/Documentation/Extensions/SunsetExtension.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
126
src/php/src/Api/Documentation/Extensions/VersionExtension.php
Normal file
126
src/php/src/Api/Documentation/Extensions/VersionExtension.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?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,6 +11,8 @@ 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;
|
||||
|
|
@ -57,7 +59,9 @@ class OpenApiBuilder
|
|||
{
|
||||
$this->extensions = [
|
||||
new WorkspaceHeaderExtension,
|
||||
new VersionExtension,
|
||||
new RateLimitExtension,
|
||||
new SunsetExtension,
|
||||
new ApiKeyAuthExtension,
|
||||
];
|
||||
}
|
||||
|
|
@ -229,6 +233,7 @@ class OpenApiBuilder
|
|||
protected function buildPaths(array $config): array
|
||||
{
|
||||
$paths = [];
|
||||
$operationIds = [];
|
||||
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
||||
$excludePatterns = $config['routes']['exclude'] ?? [];
|
||||
|
||||
|
|
@ -243,7 +248,7 @@ class OpenApiBuilder
|
|||
|
||||
foreach ($methods as $method) {
|
||||
$method = strtolower($method);
|
||||
$operation = $this->buildOperation($route, $method, $config);
|
||||
$operation = $this->buildOperation($route, $method, $config, $operationIds);
|
||||
|
||||
if ($operation !== null) {
|
||||
$paths[$path][$method] = $operation;
|
||||
|
|
@ -297,7 +302,7 @@ class OpenApiBuilder
|
|||
/**
|
||||
* Build operation for a specific route and method.
|
||||
*/
|
||||
protected function buildOperation(Route $route, string $method, array $config): ?array
|
||||
protected function buildOperation(Route $route, string $method, array $config, array &$operationIds): ?array
|
||||
{
|
||||
$controller = $route->getController();
|
||||
$action = $route->getActionMethod();
|
||||
|
|
@ -309,7 +314,7 @@ class OpenApiBuilder
|
|||
|
||||
$operation = [
|
||||
'summary' => $this->buildSummary($route, $method),
|
||||
'operationId' => $this->buildOperationId($route, $method),
|
||||
'operationId' => $this->buildOperationId($route, $method, $operationIds),
|
||||
'tags' => $this->buildOperationTags($route, $controller, $action),
|
||||
'responses' => $this->buildResponses($controller, $action),
|
||||
];
|
||||
|
|
@ -328,7 +333,7 @@ class OpenApiBuilder
|
|||
|
||||
// Add request body for POST/PUT/PATCH
|
||||
if (in_array($method, ['post', 'put', 'patch'])) {
|
||||
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
|
||||
$operation['requestBody'] = $this->buildRequestBody($route, $controller, $action);
|
||||
}
|
||||
|
||||
// Add security requirements
|
||||
|
|
@ -398,15 +403,24 @@ class OpenApiBuilder
|
|||
/**
|
||||
* Build operation ID from route name.
|
||||
*/
|
||||
protected function buildOperationId(Route $route, string $method): string
|
||||
protected function buildOperationId(Route $route, string $method, array &$operationIds): string
|
||||
{
|
||||
$name = $route->getName();
|
||||
|
||||
if ($name) {
|
||||
return Str::camel(str_replace(['.', '-'], '_', $name));
|
||||
$base = Str::camel(str_replace(['.', '-'], '_', $name));
|
||||
} else {
|
||||
$base = Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||
}
|
||||
|
||||
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||
$count = $operationIds[$base] ?? 0;
|
||||
$operationIds[$base] = $count + 1;
|
||||
|
||||
if ($count === 0) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base.'_'.($count + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -511,16 +525,36 @@ 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) {
|
||||
$parameters[] = [
|
||||
$addParameter([
|
||||
'name' => $param,
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string'],
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
// Add parameters from ApiParameter attributes
|
||||
|
|
@ -532,12 +566,12 @@ class OpenApiBuilder
|
|||
|
||||
foreach ($paramAttrs as $attr) {
|
||||
$param = $attr->newInstance();
|
||||
$parameters[] = $param->toOpenApi();
|
||||
$addParameter($param->toOpenApi());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
return array_values($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -578,15 +612,23 @@ class OpenApiBuilder
|
|||
'description' => $response->getDescription(),
|
||||
];
|
||||
|
||||
if ($response->resource !== null && class_exists($response->resource)) {
|
||||
$schema = null;
|
||||
|
||||
if (is_array($response->schema) && ! empty($response->schema)) {
|
||||
$schema = $response->schema;
|
||||
} elseif ($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'] = [
|
||||
'application/json' => [
|
||||
$contentType => [
|
||||
'schema' => $schema,
|
||||
],
|
||||
];
|
||||
|
|
@ -614,14 +656,181 @@ class OpenApiBuilder
|
|||
return ['type' => 'object'];
|
||||
}
|
||||
|
||||
// For now, return a generic object schema
|
||||
// A more sophisticated implementation would analyze the resource's toArray method
|
||||
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.
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -661,8 +870,45 @@ class OpenApiBuilder
|
|||
/**
|
||||
* Build request body schema.
|
||||
*/
|
||||
protected function buildRequestBody(?object $controller, string $action): array
|
||||
protected function buildRequestBody(Route $route, ?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,6 +20,7 @@ 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'])
|
||||
|
|
|
|||
34
src/php/src/Api/Documentation/Views/stoplight.blade.php
Normal file
34
src/php/src/Api/Documentation/Views/stoplight.blade.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!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,6 +268,13 @@ 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,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ declare(strict_types=1);
|
|||
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;
|
||||
|
||||
/**
|
||||
|
|
@ -15,6 +17,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
|||
*/
|
||||
class RateLimitExceededException extends HttpException
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function __construct(
|
||||
protected RateLimitResult $rateLimitResult,
|
||||
string $message = 'Too many requests. Please slow down.',
|
||||
|
|
@ -33,15 +37,26 @@ class RateLimitExceededException extends HttpException
|
|||
/**
|
||||
* Render the exception as a JSON response.
|
||||
*/
|
||||
public function render(): JsonResponse
|
||||
public function render(?Request $request = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'rate_limit_exceeded',
|
||||
'message' => $this->getMessage(),
|
||||
'retry_after' => $this->rateLimitResult->retryAfter,
|
||||
'limit' => $this->rateLimitResult->limit,
|
||||
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
|
||||
], 429, $this->rateLimitResult->headers());
|
||||
$response = $this->errorResponse(
|
||||
errorCode: 'rate_limit_exceeded',
|
||||
message: $this->getMessage(),
|
||||
meta: [
|
||||
'retry_after' => $this->rateLimitResult->retryAfter,
|
||||
'limit' => $this->rateLimitResult->limit,
|
||||
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Core\Api\Middleware;
|
|||
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Api\Services\IpRestrictionService;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -24,6 +25,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
*/
|
||||
class AuthenticateApiKey
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function handle(Request $request, Closure $next, ?string $scope = null): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
|
@ -113,14 +116,15 @@ class AuthenticateApiKey
|
|||
}
|
||||
|
||||
/**
|
||||
* Return 401 Unauthorized response.
|
||||
* Return 401 Unauthorised response.
|
||||
*/
|
||||
protected function unauthorized(string $message): Response
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'unauthorized',
|
||||
'message' => $message,
|
||||
], 401);
|
||||
return $this->errorResponse(
|
||||
errorCode: 'unauthorized',
|
||||
message: $message,
|
||||
status: 401,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,9 +132,6 @@ class AuthenticateApiKey
|
|||
*/
|
||||
protected function forbidden(string $message): Response
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => $message,
|
||||
], 403);
|
||||
return $this->forbiddenResponse($message, status: 403);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Core\Api\Middleware;
|
||||
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
*/
|
||||
class CheckApiScope
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function handle(Request $request, Closure $next, string ...$scopes): Response
|
||||
{
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
|
|
@ -38,12 +41,13 @@ class CheckApiScope
|
|||
// Check all required scopes
|
||||
foreach ($scopes as $scope) {
|
||||
if (! $apiKey->hasScope($scope)) {
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => "API key missing required scope: {$scope}",
|
||||
'required_scopes' => $scopes,
|
||||
'key_scopes' => $apiKey->scopes,
|
||||
], 403);
|
||||
return $this->forbiddenResponse(
|
||||
message: "API key missing required scope: {$scope}",
|
||||
meta: [
|
||||
'required_scopes' => $scopes,
|
||||
'key_scopes' => $apiKey->scopes,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace Core\Api\Middleware;
|
|||
|
||||
use Closure;
|
||||
use Core\Api\Models\ApiKey;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
*/
|
||||
class EnforceApiScope
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
/**
|
||||
* HTTP method to required scope mapping.
|
||||
*/
|
||||
|
|
@ -52,12 +55,13 @@ class EnforceApiScope
|
|||
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
|
||||
|
||||
if (! $apiKey->hasScope($requiredScope)) {
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => "API key missing required scope: {$requiredScope}",
|
||||
'detail' => "{$method} requests require '{$requiredScope}' scope",
|
||||
'key_scopes' => $apiKey->scopes,
|
||||
], 403);
|
||||
return $this->forbiddenResponse(
|
||||
message: "API key missing required scope: {$requiredScope}",
|
||||
meta: [
|
||||
'detail' => "{$method} requests require '{$requiredScope}' scope",
|
||||
'key_scopes' => $apiKey->scopes,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class ErrorResource extends JsonResource
|
|||
/**
|
||||
* Common error factory methods.
|
||||
*/
|
||||
public static function unauthorized(string $message = 'Unauthorized'): static
|
||||
public static function unauthorized(string $message = 'Unauthorised'): static
|
||||
{
|
||||
return new static('unauthorized', $message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -13,11 +18,81 @@ use Illuminate\Support\Facades\Route;
|
|||
|
|
||||
| Core API routes for cross-cutting concerns.
|
||||
|
|
||||
| TODO: SeoReportController, UnifiedPixelController, EntitlementApiController
|
||||
| are planned but not yet implemented. Re-add routes when controllers exist.
|
||||
| SEO, pixel tracking, entitlements, and MCP bridge endpoints.
|
||||
|
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -34,6 +109,8 @@ 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 Mod\Api\Services;
|
||||
namespace Core\Api\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Mod\Api\Models\ApiUsage;
|
||||
use Mod\Api\Models\ApiUsageDaily;
|
||||
use Core\Api\Models\ApiUsage;
|
||||
use Core\Api\Models\ApiUsageDaily;
|
||||
|
||||
/**
|
||||
* API Usage Service - tracks and reports API usage metrics.
|
||||
|
|
|
|||
372
src/php/src/Api/Services/SeoReportService.php
Normal file
372
src/php/src/Api/Services/SeoReportService.php
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<?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 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;
|
||||
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;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
|
|
|
|||
21
src/php/src/Api/Tests/Feature/DocumentationStoplightTest.php
Normal file
21
src/php/src/Api/Tests/Feature/DocumentationStoplightTest.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?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);
|
||||
});
|
||||
54
src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php
Normal file
54
src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue