Compare commits
261 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 | ||
|
|
39588489e3 | ||
|
|
1a4ef9fc1f | ||
|
|
8f8199cf3c | ||
|
|
d510af404d | ||
|
|
5c1f438a48 | ||
|
|
ac452b6924 | ||
|
|
b2a8a9b389 | ||
|
|
675079caf5 |
139 changed files with 20174 additions and 737 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ vendor/
|
||||||
# PHP
|
# PHP
|
||||||
/vendor/
|
/vendor/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.core/
|
||||||
|
|
|
||||||
126
api.go
126
api.go
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"iter"
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -24,23 +25,57 @@ const defaultAddr = ":8080"
|
||||||
const shutdownTimeout = 10 * time.Second
|
const shutdownTimeout = 10 * time.Second
|
||||||
|
|
||||||
// Engine is the central API server managing route groups and middleware.
|
// Engine is the central API server managing route groups and middleware.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, err := api.New(api.WithAddr(":8081"))
|
||||||
|
// if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
|
// _ = engine.Handler()
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
addr string
|
addr string
|
||||||
groups []RouteGroup
|
groups []RouteGroup
|
||||||
middlewares []gin.HandlerFunc
|
middlewares []gin.HandlerFunc
|
||||||
|
cacheTTL time.Duration
|
||||||
|
cacheMaxEntries int
|
||||||
|
cacheMaxBytes int
|
||||||
wsHandler http.Handler
|
wsHandler http.Handler
|
||||||
|
wsPath string
|
||||||
sseBroker *SSEBroker
|
sseBroker *SSEBroker
|
||||||
swaggerEnabled bool
|
swaggerEnabled bool
|
||||||
swaggerTitle string
|
swaggerTitle string
|
||||||
|
swaggerSummary string
|
||||||
swaggerDesc string
|
swaggerDesc string
|
||||||
swaggerVersion 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
|
pprofEnabled bool
|
||||||
expvarEnabled bool
|
expvarEnabled bool
|
||||||
|
ssePath string
|
||||||
graphql *graphqlConfig
|
graphql *graphqlConfig
|
||||||
|
i18nConfig I18nConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Engine with the given options.
|
// New creates an Engine with the given options.
|
||||||
// The default listen address is ":8080".
|
// The default listen address is ":8080".
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, err := api.New(api.WithAddr(":8081"), api.WithResponseMeta())
|
||||||
|
// if err != nil {
|
||||||
|
// panic(err)
|
||||||
|
// }
|
||||||
func New(opts ...Option) (*Engine, error) {
|
func New(opts ...Option) (*Engine, error) {
|
||||||
e := &Engine{
|
e := &Engine{
|
||||||
addr: defaultAddr,
|
addr: defaultAddr,
|
||||||
|
|
@ -52,27 +87,54 @@ func New(opts ...Option) (*Engine, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Addr returns the configured listen address.
|
// Addr returns the configured listen address.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithAddr(":9090"))
|
||||||
|
// addr := engine.Addr()
|
||||||
func (e *Engine) Addr() string {
|
func (e *Engine) Addr() string {
|
||||||
return e.addr
|
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 {
|
func (e *Engine) Groups() []RouteGroup {
|
||||||
return e.groups
|
return slices.Clone(e.groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupsIter returns an iterator over all registered route groups.
|
// GroupsIter returns an iterator over all registered route groups.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// for group := range engine.GroupsIter() {
|
||||||
|
// _ = group
|
||||||
|
// }
|
||||||
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
|
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
|
||||||
return slices.Values(e.groups)
|
groups := slices.Clone(e.groups)
|
||||||
|
return slices.Values(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a route group to the engine.
|
// Register adds a route group to the engine.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine.Register(myGroup)
|
||||||
func (e *Engine) Register(group RouteGroup) {
|
func (e *Engine) Register(group RouteGroup) {
|
||||||
|
if isNilRouteGroup(group) {
|
||||||
|
return
|
||||||
|
}
|
||||||
e.groups = append(e.groups, group)
|
e.groups = append(e.groups, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels returns all WebSocket channel names from registered StreamGroups.
|
// Channels returns all WebSocket channel names from registered StreamGroups.
|
||||||
// Groups that do not implement StreamGroup are silently skipped.
|
// Groups that do not implement StreamGroup are silently skipped.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// channels := engine.Channels()
|
||||||
func (e *Engine) Channels() []string {
|
func (e *Engine) Channels() []string {
|
||||||
var channels []string
|
var channels []string
|
||||||
for _, g := range e.groups {
|
for _, g := range e.groups {
|
||||||
|
|
@ -84,9 +146,16 @@ func (e *Engine) Channels() []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
|
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// for channel := range engine.ChannelsIter() {
|
||||||
|
// _ = channel
|
||||||
|
// }
|
||||||
func (e *Engine) ChannelsIter() iter.Seq[string] {
|
func (e *Engine) ChannelsIter() iter.Seq[string] {
|
||||||
|
groups := slices.Clone(e.groups)
|
||||||
return func(yield func(string) bool) {
|
return func(yield func(string) bool) {
|
||||||
for _, g := range e.groups {
|
for _, g := range groups {
|
||||||
if sg, ok := g.(StreamGroup); ok {
|
if sg, ok := g.(StreamGroup); ok {
|
||||||
for _, c := range sg.Channels() {
|
for _, c := range sg.Channels() {
|
||||||
if !yield(c) {
|
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.
|
// Handler builds the Gin engine and returns it as an http.Handler.
|
||||||
// Each call produces a fresh handler reflecting the current set of groups.
|
// Each call produces a fresh handler reflecting the current set of groups.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// handler := engine.Handler()
|
||||||
func (e *Engine) Handler() http.Handler {
|
func (e *Engine) Handler() http.Handler {
|
||||||
return e.build()
|
return e.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve starts the HTTP server and blocks until the context is cancelled,
|
// Serve starts the HTTP server and blocks until the context is cancelled,
|
||||||
// then performs a graceful shutdown allowing in-flight requests to complete.
|
// then performs a graceful shutdown allowing in-flight requests to complete.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
// defer cancel()
|
||||||
|
// _ = engine.Serve(ctx)
|
||||||
func (e *Engine) Serve(ctx context.Context) error {
|
func (e *Engine) Serve(ctx context.Context) error {
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: e.addr,
|
Addr: e.addr,
|
||||||
|
|
@ -120,8 +199,18 @@ func (e *Engine) Serve(ctx context.Context) error {
|
||||||
close(errCh)
|
close(errCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Block until context is cancelled.
|
// Return immediately if the listener fails before shutdown is requested.
|
||||||
<-ctx.Done()
|
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.
|
// Graceful shutdown with timeout.
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
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.
|
// user-supplied middleware, the health endpoint, and all registered route groups.
|
||||||
func (e *Engine) build() *gin.Engine {
|
func (e *Engine) build() *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(recoveryMiddleware())
|
||||||
|
|
||||||
// Apply user-supplied middleware after recovery but before routes.
|
// Apply user-supplied middleware after recovery but before routes.
|
||||||
for _, mw := range e.middlewares {
|
for _, mw := range e.middlewares {
|
||||||
|
|
@ -153,18 +242,21 @@ func (e *Engine) build() *gin.Engine {
|
||||||
|
|
||||||
// Mount each registered group at its base path.
|
// Mount each registered group at its base path.
|
||||||
for _, g := range e.groups {
|
for _, g := range e.groups {
|
||||||
|
if isNilRouteGroup(g) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
rg := r.Group(g.BasePath())
|
rg := r.Group(g.BasePath())
|
||||||
g.RegisterRoutes(rg)
|
g.RegisterRoutes(rg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount WebSocket handler if configured.
|
// Mount WebSocket handler if configured.
|
||||||
if e.wsHandler != nil {
|
if e.wsHandler != nil {
|
||||||
r.GET("/ws", wrapWSHandler(e.wsHandler))
|
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount SSE endpoint if configured.
|
// Mount SSE endpoint if configured.
|
||||||
if e.sseBroker != nil {
|
if e.sseBroker != nil {
|
||||||
r.GET("/events", e.sseBroker.Handler())
|
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount GraphQL endpoint if configured.
|
// Mount GraphQL endpoint if configured.
|
||||||
|
|
@ -174,7 +266,7 @@ func (e *Engine) build() *gin.Engine {
|
||||||
|
|
||||||
// Mount Swagger UI if enabled.
|
// Mount Swagger UI if enabled.
|
||||||
if e.swaggerEnabled {
|
if e.swaggerEnabled {
|
||||||
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
|
registerSwagger(r, e, e.groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount pprof profiling endpoints if enabled.
|
// Mount pprof profiling endpoints if enabled.
|
||||||
|
|
@ -189,3 +281,17 @@ func (e *Engine) build() *gin.Engine {
|
||||||
|
|
||||||
return r
|
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"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Test helpers ────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────────────────
|
// ── New ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestNew_Good(t *testing.T) {
|
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 ─────────────────────────────────────────────────────────────
|
// ── Handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestHandler_Good_HealthEndpoint(t *testing.T) {
|
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 ───────────────────────────────────────────
|
// ── Serve + graceful shutdown ───────────────────────────────────────────
|
||||||
|
|
||||||
func TestServe_Good_GracefulShutdown(t *testing.T) {
|
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")
|
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.
|
// AuthentikConfig holds settings for the Authentik forward-auth integration.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := api.AuthentikConfig{Issuer: "https://auth.example.com/", ClientID: "core-api"}
|
||||||
type AuthentikConfig struct {
|
type AuthentikConfig struct {
|
||||||
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
|
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
|
||||||
Issuer string
|
Issuer string
|
||||||
|
|
@ -26,12 +30,32 @@ type AuthentikConfig struct {
|
||||||
TrustedProxy bool
|
TrustedProxy bool
|
||||||
|
|
||||||
// PublicPaths lists additional paths that do not require authentication.
|
// PublicPaths lists additional paths that do not require authentication.
|
||||||
// /health and /swagger are always public.
|
// /health and the configured Swagger UI path are always public.
|
||||||
PublicPaths []string
|
PublicPaths []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthentikConfig returns the configured Authentik settings for the engine.
|
||||||
|
//
|
||||||
|
// The result snapshots the Engine state at call time and clones slices so
|
||||||
|
// callers can safely reuse or modify the returned value.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := engine.AuthentikConfig()
|
||||||
|
func (e *Engine) AuthentikConfig() AuthentikConfig {
|
||||||
|
if e == nil {
|
||||||
|
return AuthentikConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneAuthentikConfig(e.authentikConfig)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthentikUser represents an authenticated user extracted from Authentik
|
// AuthentikUser represents an authenticated user extracted from Authentik
|
||||||
// forward-auth headers or a validated JWT.
|
// forward-auth headers or a validated JWT.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// user := &api.AuthentikUser{Username: "alice", Groups: []string{"admins"}}
|
||||||
type AuthentikUser struct {
|
type AuthentikUser struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
@ -43,6 +67,10 @@ type AuthentikUser struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasGroup reports whether the user belongs to the named group.
|
// HasGroup reports whether the user belongs to the named group.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// user.HasGroup("admins")
|
||||||
func (u *AuthentikUser) HasGroup(group string) bool {
|
func (u *AuthentikUser) HasGroup(group string) bool {
|
||||||
return slices.Contains(u.Groups, group)
|
return slices.Contains(u.Groups, group)
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +81,10 @@ const authentikUserKey = "authentik_user"
|
||||||
// GetUser retrieves the AuthentikUser from the Gin context.
|
// GetUser retrieves the AuthentikUser from the Gin context.
|
||||||
// Returns nil when no user has been set (unauthenticated request or
|
// Returns nil when no user has been set (unauthenticated request or
|
||||||
// middleware not active).
|
// middleware not active).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// user := api.GetUser(c)
|
||||||
func GetUser(c *gin.Context) *AuthentikUser {
|
func GetUser(c *gin.Context) *AuthentikUser {
|
||||||
val, exists := c.Get(authentikUserKey)
|
val, exists := c.Get(authentikUserKey)
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -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
|
// The middleware is PERMISSIVE: it populates the context when credentials are
|
||||||
// present but never rejects unauthenticated requests. Downstream handlers
|
// present but never rejects unauthenticated requests. Downstream handlers
|
||||||
// use GetUser to check authentication.
|
// use GetUser to check authentication.
|
||||||
func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
|
func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.HandlerFunc {
|
||||||
// Build the set of public paths that skip header extraction entirely.
|
// Build the set of public paths that skip header extraction entirely.
|
||||||
public := map[string]bool{
|
public := map[string]bool{
|
||||||
"/health": true,
|
"/health": true,
|
||||||
|
|
@ -148,11 +180,19 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
|
||||||
// Skip public paths.
|
// Skip public paths.
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
for p := range public {
|
for p := range public {
|
||||||
if strings.HasPrefix(path, p) {
|
if isPublicPath(path, p) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if publicPaths != nil {
|
||||||
|
for _, p := range publicPaths() {
|
||||||
|
if isPublicPath(path, p) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Block 1: Extract user from X-authentik-* forward-auth headers.
|
// Block 1: Extract user from X-authentik-* forward-auth headers.
|
||||||
if cfg.TrustedProxy {
|
if 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.
|
// RequireAuth is Gin middleware that rejects unauthenticated requests.
|
||||||
// It checks for a user set by the Authentik middleware and returns 401
|
// It checks for a user set by the Authentik middleware and returns 401
|
||||||
// when none is present.
|
// when none is present.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// r.GET("/private", api.RequireAuth(), handler)
|
||||||
func RequireAuth() gin.HandlerFunc {
|
func RequireAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if GetUser(c) == nil {
|
if GetUser(c) == nil {
|
||||||
|
|
@ -210,6 +298,10 @@ func RequireAuth() gin.HandlerFunc {
|
||||||
// RequireGroup is Gin middleware that rejects requests from users who do
|
// RequireGroup is Gin middleware that rejects requests from users who do
|
||||||
// not belong to the specified group. Returns 401 when no user is present
|
// not belong to the specified group. Returns 401 when no user is present
|
||||||
// and 403 when the user lacks the required group membership.
|
// and 403 when the user lacks the required group membership.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// r.GET("/admin", api.RequireGroup("admins"), handler)
|
||||||
func RequireGroup(group string) gin.HandlerFunc {
|
func RequireGroup(group string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
user := GetUser(c)
|
user := GetUser(c)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── AuthentikUser ──────────────────────────────────────────────────────
|
// ── 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) {
|
func TestGetUser_Good_NilContext(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
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 ────────────────────────────────────────
|
// ── RequireAuth / RequireGroup ────────────────────────────────────────
|
||||||
|
|
||||||
func TestRequireAuth_Good(t *testing.T) {
|
func TestRequireAuth_Good(t *testing.T) {
|
||||||
|
|
@ -458,3 +506,15 @@ func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
c.JSON(200, api.OK("admin panel"))
|
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/casbin/casbin/v2/model"
|
||||||
"github.com/gin-gonic/gin"
|
"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.
|
// casbinModel is a minimal RESTful ACL model for testing authorisation.
|
||||||
|
|
|
||||||
831
bridge.go
831
bridge.go
|
|
@ -3,12 +3,30 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"iter"
|
"iter"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
|
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// desc := api.ToolDescriptor{Name: "ping", Description: "Ping the service"}
|
||||||
type ToolDescriptor struct {
|
type ToolDescriptor struct {
|
||||||
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
|
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
|
||||||
Description string // Human-readable description
|
Description string // Human-readable description
|
||||||
|
|
@ -19,6 +37,10 @@ type ToolDescriptor struct {
|
||||||
|
|
||||||
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
|
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
|
||||||
// It implements both RouteGroup and DescribableGroup.
|
// It implements both RouteGroup and DescribableGroup.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// bridge := api.NewToolBridge("/mcp")
|
||||||
type ToolBridge struct {
|
type ToolBridge struct {
|
||||||
basePath string
|
basePath string
|
||||||
name string
|
name string
|
||||||
|
|
@ -30,7 +52,14 @@ type boundTool struct {
|
||||||
handler gin.HandlerFunc
|
handler gin.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ RouteGroup = (*ToolBridge)(nil)
|
||||||
|
var _ DescribableGroup = (*ToolBridge)(nil)
|
||||||
|
|
||||||
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
|
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// bridge := api.NewToolBridge("/mcp")
|
||||||
func NewToolBridge(basePath string) *ToolBridge {
|
func NewToolBridge(basePath string) *ToolBridge {
|
||||||
return &ToolBridge{
|
return &ToolBridge{
|
||||||
basePath: basePath,
|
basePath: basePath,
|
||||||
|
|
@ -39,17 +68,39 @@ func NewToolBridge(basePath string) *ToolBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add registers a tool with its HTTP handler.
|
// Add registers a tool with its HTTP handler.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// bridge.Add(api.ToolDescriptor{Name: "ping", Description: "Ping the service"}, handler)
|
||||||
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
|
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
|
||||||
|
if validator := newToolInputValidator(desc.OutputSchema); validator != nil {
|
||||||
|
handler = wrapToolResponseHandler(handler, validator)
|
||||||
|
}
|
||||||
|
if validator := newToolInputValidator(desc.InputSchema); validator != nil {
|
||||||
|
handler = wrapToolHandler(handler, validator)
|
||||||
|
}
|
||||||
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
|
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the bridge identifier.
|
// Name returns the bridge identifier.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// name := bridge.Name()
|
||||||
func (b *ToolBridge) Name() string { return b.name }
|
func (b *ToolBridge) Name() string { return b.name }
|
||||||
|
|
||||||
// BasePath returns the URL prefix for all tool endpoints.
|
// BasePath returns the URL prefix for all tool endpoints.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// path := bridge.BasePath()
|
||||||
func (b *ToolBridge) BasePath() string { return b.basePath }
|
func (b *ToolBridge) BasePath() string { return b.basePath }
|
||||||
|
|
||||||
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
|
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// bridge.RegisterRoutes(rg)
|
||||||
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
for _, t := range b.tools {
|
for _, t := range b.tools {
|
||||||
rg.POST("/"+t.descriptor.Name, t.handler)
|
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.
|
// Describe returns OpenAPI route descriptions for all registered tools.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// descs := bridge.Describe()
|
||||||
func (b *ToolBridge) Describe() []RouteDescription {
|
func (b *ToolBridge) Describe() []RouteDescription {
|
||||||
descs := make([]RouteDescription, 0, len(b.tools))
|
tools := b.snapshotTools()
|
||||||
for _, t := range b.tools {
|
descs := make([]RouteDescription, 0, len(tools))
|
||||||
tags := []string{t.descriptor.Group}
|
for _, tool := range tools {
|
||||||
if t.descriptor.Group == "" {
|
descs = append(descs, describeTool(tool.descriptor, b.name))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return descs
|
return descs
|
||||||
}
|
}
|
||||||
|
|
||||||
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
|
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// for rd := range bridge.DescribeIter() {
|
||||||
|
// _ = rd
|
||||||
|
// }
|
||||||
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
||||||
|
tools := b.snapshotTools()
|
||||||
return func(yield func(RouteDescription) bool) {
|
return func(yield func(RouteDescription) bool) {
|
||||||
for _, t := range b.tools {
|
for _, tool := range tools {
|
||||||
tags := []string{t.descriptor.Group}
|
if !yield(describeTool(tool.descriptor, b.name)) {
|
||||||
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) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,21 +140,746 @@ func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools returns all registered tool descriptors.
|
// Tools returns all registered tool descriptors.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// descs := bridge.Tools()
|
||||||
func (b *ToolBridge) Tools() []ToolDescriptor {
|
func (b *ToolBridge) Tools() []ToolDescriptor {
|
||||||
descs := make([]ToolDescriptor, len(b.tools))
|
tools := b.snapshotTools()
|
||||||
for i, t := range b.tools {
|
descs := make([]ToolDescriptor, len(tools))
|
||||||
|
for i, t := range tools {
|
||||||
descs[i] = t.descriptor
|
descs[i] = t.descriptor
|
||||||
}
|
}
|
||||||
return descs
|
return descs
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolsIter returns an iterator over all registered tool descriptors.
|
// ToolsIter returns an iterator over all registered tool descriptors.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// for desc := range bridge.ToolsIter() {
|
||||||
|
// _ = desc
|
||||||
|
// }
|
||||||
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
|
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
|
||||||
|
tools := b.snapshotTools()
|
||||||
return func(yield func(ToolDescriptor) bool) {
|
return func(yield func(ToolDescriptor) bool) {
|
||||||
for _, t := range b.tools {
|
for _, tool := range tools {
|
||||||
if !yield(t.descriptor) {
|
if !yield(tool.descriptor) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *ToolBridge) snapshotTools() []boundTool {
|
||||||
|
if len(b.tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return slices.Clone(b.tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeTool(desc ToolDescriptor, defaultTag string) RouteDescription {
|
||||||
|
tags := cleanTags([]string{desc.Group})
|
||||||
|
if len(tags) == 0 {
|
||||||
|
tags = []string{defaultTag}
|
||||||
|
}
|
||||||
|
return RouteDescription{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/" + desc.Name,
|
||||||
|
Summary: desc.Description,
|
||||||
|
Description: desc.Description,
|
||||||
|
Tags: tags,
|
||||||
|
RequestBody: desc.InputSchema,
|
||||||
|
Response: desc.OutputSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapToolHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
|
||||||
|
"invalid_request_body",
|
||||||
|
"Unable to read request body",
|
||||||
|
map[string]any{"error": err.Error()},
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.Validate(body); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
|
||||||
|
"invalid_request_body",
|
||||||
|
"Request body does not match the declared tool schema",
|
||||||
|
map[string]any{"error": err.Error()},
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
handler(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapToolResponseHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
recorder := newToolResponseRecorder(c.Writer)
|
||||||
|
c.Writer = recorder
|
||||||
|
|
||||||
|
handler(c)
|
||||||
|
|
||||||
|
if recorder.Status() >= 200 && recorder.Status() < 300 {
|
||||||
|
if err := validator.ValidateResponse(recorder.body.Bytes()); err != nil {
|
||||||
|
recorder.reset()
|
||||||
|
recorder.writeErrorResponse(http.StatusInternalServerError, FailWithDetails(
|
||||||
|
"invalid_tool_response",
|
||||||
|
"Tool response does not match the declared output schema",
|
||||||
|
map[string]any{"error": err.Error()},
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolInputValidator struct {
|
||||||
|
schema map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolInputValidator(schema map[string]any) *toolInputValidator {
|
||||||
|
if len(schema) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &toolInputValidator{schema: schema}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *toolInputValidator) Validate(body []byte) error {
|
||||||
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
|
return coreerr.E("ToolBridge.Validate", "request body is required", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
dec.UseNumber()
|
||||||
|
|
||||||
|
var payload any
|
||||||
|
if err := dec.Decode(&payload); err != nil {
|
||||||
|
return coreerr.E("ToolBridge.Validate", "invalid JSON", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := dec.Decode(&extra); err != io.EOF {
|
||||||
|
return coreerr.E("ToolBridge.Validate", "request body must contain a single JSON value", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateSchemaNode(payload, v.schema, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *toolInputValidator) ValidateResponse(body []byte) error {
|
||||||
|
if len(v.schema) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope map[string]any
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
return coreerr.E("ToolBridge.ValidateResponse", "invalid JSON response", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
success, _ := envelope["success"].(bool)
|
||||||
|
if !success {
|
||||||
|
return coreerr.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := envelope["data"]
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("ToolBridge.ValidateResponse", "response is missing data", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("ToolBridge.ValidateResponse", "encode response data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload any
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(encoded))
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&payload); err != nil {
|
||||||
|
return coreerr.E("ToolBridge.ValidateResponse", "decode response data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateSchemaNode(payload, v.schema, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSchemaNode(value any, schema map[string]any, path string) error {
|
||||||
|
if len(schema) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaType, _ := schema["type"].(string)
|
||||||
|
if schemaType != "" {
|
||||||
|
switch schemaType {
|
||||||
|
case "object":
|
||||||
|
obj, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return typeError(path, "object", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range stringList(schema["required"]) {
|
||||||
|
if _, ok := obj[name]; !ok {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s is missing required field %q", displayPath(path), name), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rawChild := range schemaMap(schema["properties"]) {
|
||||||
|
childSchema, ok := rawChild.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childValue, ok := obj[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateSchemaNode(childValue, childSchema, joinPath(path, name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if additionalProperties, ok := schema["additionalProperties"].(bool); ok && !additionalProperties {
|
||||||
|
properties := schemaMap(schema["properties"])
|
||||||
|
for name := range obj {
|
||||||
|
if properties != nil {
|
||||||
|
if _, ok := properties[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s contains unknown field %q", displayPath(path), name), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateObjectConstraints(obj, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "array":
|
||||||
|
arr, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return typeError(path, "array", value)
|
||||||
|
}
|
||||||
|
if items := schemaMap(schema["items"]); len(items) > 0 {
|
||||||
|
for i, item := range arr {
|
||||||
|
if err := validateSchemaNode(item, items, joinPath(path, strconv.Itoa(i))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateArrayConstraints(arr, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "string":
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return typeError(path, "string", value)
|
||||||
|
}
|
||||||
|
if err := validateStringConstraints(str, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
if _, ok := value.(bool); !ok {
|
||||||
|
return typeError(path, "boolean", value)
|
||||||
|
}
|
||||||
|
case "integer":
|
||||||
|
if !isIntegerValue(value) {
|
||||||
|
return typeError(path, "integer", value)
|
||||||
|
}
|
||||||
|
if err := validateNumericConstraints(value, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
if !isNumberValue(value) {
|
||||||
|
return typeError(path, "number", value)
|
||||||
|
}
|
||||||
|
if err := validateNumericConstraints(value, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if schemaType == "" && (len(schemaMap(schema["properties"])) > 0 || schema["required"] != nil || schema["additionalProperties"] != nil) {
|
||||||
|
props := schemaMap(schema["properties"])
|
||||||
|
return validateSchemaNode(value, map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
"required": schema["required"],
|
||||||
|
"additionalProperties": schema["additionalProperties"],
|
||||||
|
}, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawEnum, ok := schema["enum"]; ok {
|
||||||
|
if !enumContains(value, rawEnum) {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSchemaCombinators(value, schema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSchemaCombinators(value any, schema map[string]any, path string) error {
|
||||||
|
if subschemas := schemaObjects(schema["allOf"]); len(subschemas) > 0 {
|
||||||
|
for _, subschema := range subschemas {
|
||||||
|
if err := validateSchemaNode(value, subschema, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subschemas := schemaObjects(schema["anyOf"]); len(subschemas) > 0 {
|
||||||
|
for _, subschema := range subschemas {
|
||||||
|
if err := validateSchemaNode(value, subschema, path); err == nil {
|
||||||
|
goto anyOfMatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
anyOfMatched:
|
||||||
|
if subschemas := schemaObjects(schema["oneOf"]); len(subschemas) > 0 {
|
||||||
|
matches := 0
|
||||||
|
for _, subschema := range subschemas {
|
||||||
|
if err := validateSchemaNode(value, subschema, path); err == nil {
|
||||||
|
matches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches != 1 {
|
||||||
|
if matches == 0 {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil)
|
||||||
|
}
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subschema, ok := schema["not"].(map[string]any); ok && subschema != nil {
|
||||||
|
if err := validateSchemaNode(value, subschema, path); err == nil {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStringConstraints(value string, schema map[string]any, path string) error {
|
||||||
|
length := utf8.RuneCountInString(value)
|
||||||
|
if minLength, ok := schemaInt(schema["minLength"]); ok && length < minLength {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil)
|
||||||
|
}
|
||||||
|
if maxLength, ok := schemaInt(schema["maxLength"]); ok && length > maxLength {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil)
|
||||||
|
}
|
||||||
|
if pattern, ok := schema["pattern"].(string); ok && pattern != "" {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err)
|
||||||
|
}
|
||||||
|
if !re.MatchString(value) {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNumericConstraints(value any, schema map[string]any, path string) error {
|
||||||
|
if minimum, ok := schemaFloat(schema["minimum"]); ok && numericLessThan(value, minimum) {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil)
|
||||||
|
}
|
||||||
|
if maximum, ok := schemaFloat(schema["maximum"]); ok && numericGreaterThan(value, maximum) {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateArrayConstraints(value []any, schema map[string]any, path string) error {
|
||||||
|
if minItems, ok := schemaInt(schema["minItems"]); ok && len(value) < minItems {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil)
|
||||||
|
}
|
||||||
|
if maxItems, ok := schemaInt(schema["maxItems"]); ok && len(value) > maxItems {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateObjectConstraints(value map[string]any, schema map[string]any, path string) error {
|
||||||
|
if minProps, ok := schemaInt(schema["minProperties"]); ok && len(value) < minProps {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil)
|
||||||
|
}
|
||||||
|
if maxProps, ok := schemaInt(schema["maxProperties"]); ok && len(value) > maxProps {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaInt(value any) (int, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case int:
|
||||||
|
return v, true
|
||||||
|
case int8:
|
||||||
|
return int(v), true
|
||||||
|
case int16:
|
||||||
|
return int(v), true
|
||||||
|
case int32:
|
||||||
|
return int(v), true
|
||||||
|
case int64:
|
||||||
|
return int(v), true
|
||||||
|
case uint:
|
||||||
|
return int(v), true
|
||||||
|
case uint8:
|
||||||
|
return int(v), true
|
||||||
|
case uint16:
|
||||||
|
return int(v), true
|
||||||
|
case uint32:
|
||||||
|
return int(v), true
|
||||||
|
case uint64:
|
||||||
|
return int(v), true
|
||||||
|
case float64:
|
||||||
|
if v == float64(int(v)) {
|
||||||
|
return int(v), true
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
if n, err := v.Int64(); err == nil {
|
||||||
|
return int(n), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaFloat(value any) (float64, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case float32:
|
||||||
|
return float64(v), true
|
||||||
|
case int:
|
||||||
|
return float64(v), true
|
||||||
|
case int8:
|
||||||
|
return float64(v), true
|
||||||
|
case int16:
|
||||||
|
return float64(v), true
|
||||||
|
case int32:
|
||||||
|
return float64(v), true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
case uint:
|
||||||
|
return float64(v), true
|
||||||
|
case uint8:
|
||||||
|
return float64(v), true
|
||||||
|
case uint16:
|
||||||
|
return float64(v), true
|
||||||
|
case uint32:
|
||||||
|
return float64(v), true
|
||||||
|
case uint64:
|
||||||
|
return float64(v), true
|
||||||
|
case json.Number:
|
||||||
|
if n, err := v.Float64(); err == nil {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericLessThan(value any, limit float64) bool {
|
||||||
|
if n, ok := numericValue(value); ok {
|
||||||
|
return n < limit
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericGreaterThan(value any, limit float64) bool {
|
||||||
|
if n, ok := numericValue(value); ok {
|
||||||
|
return n > limit
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolResponseRecorder struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
headers http.Header
|
||||||
|
body bytes.Buffer
|
||||||
|
status int
|
||||||
|
wroteHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolResponseRecorder(w gin.ResponseWriter) *toolResponseRecorder {
|
||||||
|
headers := make(http.Header)
|
||||||
|
for k, vals := range w.Header() {
|
||||||
|
headers[k] = append([]string(nil), vals...)
|
||||||
|
}
|
||||||
|
return &toolResponseRecorder{
|
||||||
|
ResponseWriter: w,
|
||||||
|
headers: headers,
|
||||||
|
status: http.StatusOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Header() http.Header {
|
||||||
|
return w.headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) WriteHeader(code int) {
|
||||||
|
w.status = code
|
||||||
|
w.wroteHeader = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) WriteHeaderNow() {
|
||||||
|
w.wroteHeader = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Write(data []byte) (int, error) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.body.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) WriteString(s string) (int, error) {
|
||||||
|
if !w.wroteHeader {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.body.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Flush() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Status() int {
|
||||||
|
if w.wroteHeader {
|
||||||
|
return w.status
|
||||||
|
}
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Size() int {
|
||||||
|
return w.body.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Written() bool {
|
||||||
|
return w.wroteHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
return nil, nil, coreerr.E("ToolBridge.ResponseRecorder", "response hijacking is not supported by ToolBridge output validation", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) commit() {
|
||||||
|
for k := range w.ResponseWriter.Header() {
|
||||||
|
w.ResponseWriter.Header().Del(k)
|
||||||
|
}
|
||||||
|
for k, vals := range w.headers {
|
||||||
|
for _, v := range vals {
|
||||||
|
w.ResponseWriter.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.ResponseWriter.WriteHeader(w.Status())
|
||||||
|
_, _ = w.ResponseWriter.Write(w.body.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) reset() {
|
||||||
|
w.headers = make(http.Header)
|
||||||
|
w.body.Reset()
|
||||||
|
w.status = http.StatusInternalServerError
|
||||||
|
w.wroteHeader = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]) {
|
||||||
|
data, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w.ResponseWriter, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ResponseWriter.Header().Set("Content-Type", "application/json")
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
_, _ = w.ResponseWriter.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeError(path, want string, value any) error {
|
||||||
|
return coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "request body"
|
||||||
|
}
|
||||||
|
return "request body." + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(parent, child string) string {
|
||||||
|
if parent == "" {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
return parent + "." + child
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaMap(value any) map[string]any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := value.(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaObjects(value any) []map[string]any {
|
||||||
|
switch raw := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
if schema := schemaMap(item); schema != nil {
|
||||||
|
out = append(out, schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
return append([]map[string]any(nil), raw...)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringList(value any) []string {
|
||||||
|
switch raw := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
name, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
return append([]string(nil), raw...)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIntegerValue(value any) bool {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
_, err := v.Int64()
|
||||||
|
return err == nil
|
||||||
|
case float64:
|
||||||
|
return v == float64(int64(v))
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumberValue(value any) bool {
|
||||||
|
switch value.(type) {
|
||||||
|
case json.Number, float64:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumContains(value any, rawEnum any) bool {
|
||||||
|
items := enumValues(rawEnum)
|
||||||
|
for _, candidate := range items {
|
||||||
|
if valuesEqual(value, candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumValues(rawEnum any) []any {
|
||||||
|
switch values := rawEnum.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]any, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
out := make([]any, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valuesEqual(left, right any) bool {
|
||||||
|
if isNumericValue(left) && isNumericValue(right) {
|
||||||
|
lv, lok := numericValue(left)
|
||||||
|
rv, rok := numericValue(right)
|
||||||
|
return lok && rok && lv == rv
|
||||||
|
}
|
||||||
|
return reflect.DeepEqual(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumericValue(value any) bool {
|
||||||
|
switch value.(type) {
|
||||||
|
case json.Number, float64, float32, int, int8, int16, int32, int64,
|
||||||
|
uint, uint8, uint16, uint32, uint64:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericValue(value any) (float64, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Float64()
|
||||||
|
return n, err == nil
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case float32:
|
||||||
|
return float64(v), true
|
||||||
|
case int:
|
||||||
|
return float64(v), true
|
||||||
|
case int8:
|
||||||
|
return float64(v), true
|
||||||
|
case int16:
|
||||||
|
return float64(v), true
|
||||||
|
case int32:
|
||||||
|
return float64(v), true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
case uint:
|
||||||
|
return float64(v), true
|
||||||
|
case uint8:
|
||||||
|
return float64(v), true
|
||||||
|
case uint16:
|
||||||
|
return float64(v), true
|
||||||
|
case uint32:
|
||||||
|
return float64(v), true
|
||||||
|
case uint64:
|
||||||
|
return float64(v), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeJSONValue(value any) string {
|
||||||
|
switch value.(type) {
|
||||||
|
case nil:
|
||||||
|
return "null"
|
||||||
|
case string:
|
||||||
|
return "string"
|
||||||
|
case bool:
|
||||||
|
return "boolean"
|
||||||
|
case json.Number, float64:
|
||||||
|
return "number"
|
||||||
|
case map[string]any:
|
||||||
|
return "object"
|
||||||
|
case []any:
|
||||||
|
return "array"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
519
bridge_test.go
519
bridge_test.go
|
|
@ -3,6 +3,7 @@
|
||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -10,7 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── ToolBridge ─────────────────────────────────────────────────────────
|
// ── 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) {
|
func TestToolBridge_Good_ToolsAccessor(t *testing.T) {
|
||||||
bridge := api.NewToolBridge("/tools")
|
bridge := api.NewToolBridge("/tools")
|
||||||
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})
|
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WithBrotli ────────────────────────────────────────────────────────
|
// ── WithBrotli ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
147
cache.go
147
cache.go
|
|
@ -4,8 +4,10 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"container/list"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -17,6 +19,7 @@ type cacheEntry struct {
|
||||||
status int
|
status int
|
||||||
headers http.Header
|
headers http.Header
|
||||||
body []byte
|
body []byte
|
||||||
|
size int
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,27 +27,49 @@ type cacheEntry struct {
|
||||||
type cacheStore struct {
|
type cacheStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]*cacheEntry
|
entries map[string]*cacheEntry
|
||||||
|
order *list.List
|
||||||
|
index map[string]*list.Element
|
||||||
|
maxEntries int
|
||||||
|
maxBytes int
|
||||||
|
currentBytes int
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCacheStore creates an empty cache store.
|
// newCacheStore creates an empty cache store.
|
||||||
func newCacheStore() *cacheStore {
|
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
|
||||||
return &cacheStore{
|
return &cacheStore{
|
||||||
entries: make(map[string]*cacheEntry),
|
entries: make(map[string]*cacheEntry),
|
||||||
|
order: list.New(),
|
||||||
|
index: make(map[string]*list.Element),
|
||||||
|
maxEntries: maxEntries,
|
||||||
|
maxBytes: maxBytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get retrieves a non-expired entry for the given key.
|
// get retrieves a non-expired entry for the given key.
|
||||||
// Returns nil if the key is missing or expired.
|
// Returns nil if the key is missing or expired.
|
||||||
func (s *cacheStore) get(key string) *cacheEntry {
|
func (s *cacheStore) get(key string) *cacheEntry {
|
||||||
s.mu.RLock()
|
s.mu.Lock()
|
||||||
entry, ok := s.entries[key]
|
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 {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if time.Now().After(entry.expires) {
|
if time.Now().After(entry.expires) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
if elem, exists := s.index[key]; exists {
|
||||||
|
s.order.Remove(elem)
|
||||||
|
delete(s.index, key)
|
||||||
|
}
|
||||||
|
s.currentBytes -= entry.size
|
||||||
|
if s.currentBytes < 0 {
|
||||||
|
s.currentBytes = 0
|
||||||
|
}
|
||||||
delete(s.entries, key)
|
delete(s.entries, key)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -55,8 +80,79 @@ func (s *cacheStore) get(key string) *cacheEntry {
|
||||||
// set stores a cache entry with the given TTL.
|
// set stores a cache entry with the given TTL.
|
||||||
func (s *cacheStore) set(key string, entry *cacheEntry) {
|
func (s *cacheStore) set(key string, entry *cacheEntry) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
if entry.size <= 0 {
|
||||||
|
entry.size = cacheEntrySize(entry.headers, entry.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if elem, ok := s.index[key]; ok {
|
||||||
|
if existing, exists := s.entries[key]; exists {
|
||||||
|
s.currentBytes -= existing.size
|
||||||
|
if s.currentBytes < 0 {
|
||||||
|
s.currentBytes = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.order.MoveToFront(elem)
|
||||||
s.entries[key] = entry
|
s.entries[key] = entry
|
||||||
|
s.currentBytes += entry.size
|
||||||
|
s.evictBySizeLocked()
|
||||||
s.mu.Unlock()
|
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.
|
// cacheWriter intercepts writes to capture the response body and status.
|
||||||
|
|
@ -89,14 +185,31 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
||||||
|
|
||||||
// Serve from cache if a valid entry exists.
|
// Serve from cache if a valid entry exists.
|
||||||
if entry := store.get(key); entry != nil {
|
if entry := store.get(key); entry != nil {
|
||||||
for k, vals := range entry.headers {
|
body := entry.body
|
||||||
for _, v := range vals {
|
if meta := GetRequestMeta(c); meta != nil {
|
||||||
c.Writer.Header().Set(k, v)
|
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().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestID := GetRequestID(c); requestID != "" {
|
||||||
|
c.Writer.Header().Set("X-Request-ID", requestID)
|
||||||
|
} else if requestID := c.GetHeader("X-Request-ID"); requestID != "" {
|
||||||
|
c.Writer.Header().Set("X-Request-ID", requestID)
|
||||||
}
|
}
|
||||||
c.Writer.Header().Set("X-Cache", "HIT")
|
c.Writer.Header().Set("X-Cache", "HIT")
|
||||||
|
c.Writer.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
c.Writer.WriteHeader(entry.status)
|
c.Writer.WriteHeader(entry.status)
|
||||||
_, _ = c.Writer.Write(entry.body)
|
_, _ = c.Writer.Write(body)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -119,8 +232,28 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
||||||
status: status,
|
status: status,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: cw.body.Bytes(),
|
body: cw.body.Bytes(),
|
||||||
|
size: cacheEntrySize(headers, cw.body.Bytes()),
|
||||||
expires: time.Now().Add(ttl),
|
expires: time.Now().Add(ttl),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshCachedResponseMeta updates the meta envelope in a cached JSON body so
|
||||||
|
// request-scoped metadata reflects the current request instead of the cache fill.
|
||||||
|
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
|
||||||
|
// returned unchanged.
|
||||||
|
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
|
||||||
|
return refreshResponseMetaBody(body, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheEntrySize(headers http.Header, body []byte) int {
|
||||||
|
size := len(body)
|
||||||
|
for key, vals := range headers {
|
||||||
|
size += len(key)
|
||||||
|
for _, val := range vals {
|
||||||
|
size += len(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
|
||||||
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"
|
"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,
|
// 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 ───────────────────────────────────────────────────────────
|
// ── WithCache ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
|
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) {
|
func TestWithCache_Good_POSTNotCached(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
grp := &cacheCounterGroup{}
|
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) {
|
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
grp := &cacheCounterGroup{}
|
grp := &cacheCounterGroup{}
|
||||||
|
|
@ -250,3 +480,75 @@ func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
|
||||||
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
|
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)
|
cli.RegisterCommands(AddAPICommands)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAPICommands registers the 'api' command group.
|
// AddAPICommands registers the `api` command group.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// root := &cli.Command{Use: "root"}
|
||||||
|
// api.AddAPICommands(root)
|
||||||
func AddAPICommands(root *cli.Command) {
|
func AddAPICommands(root *cli.Command) {
|
||||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||||
root.AddCommand(apiCmd)
|
root.AddCommand(apiCmd)
|
||||||
|
|
|
||||||
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,14 +3,23 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
goapi "forge.lthn.ai/core/api"
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
|
||||||
|
goapi "dappco.re/go/core/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSDKTitle = "Lethean Core API"
|
||||||
|
defaultSDKDescription = "Lethean Core API"
|
||||||
|
defaultSDKVersion = "1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addSDKCommand(parent *cli.Command) {
|
func addSDKCommand(parent *cli.Command) {
|
||||||
|
|
@ -19,40 +28,20 @@ func addSDKCommand(parent *cli.Command) {
|
||||||
output string
|
output string
|
||||||
specFile string
|
specFile string
|
||||||
packageName string
|
packageName string
|
||||||
|
cfg specBuilderConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cfg.title = defaultSDKTitle
|
||||||
|
cfg.description = defaultSDKDescription
|
||||||
|
cfg.version = defaultSDKVersion
|
||||||
|
|
||||||
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
||||||
if lang == "" {
|
languages := splitUniqueCSV(lang)
|
||||||
return fmt.Errorf("--lang is required. Supported: %s", strings.Join(goapi.SupportedLanguages(), ", "))
|
if len(languages) == 0 {
|
||||||
}
|
return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
|
||||||
|
|
||||||
// If 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 fmt.Errorf("create temp spec file: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpFile.Name())
|
|
||||||
|
|
||||||
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
|
|
||||||
tmpFile.Close()
|
|
||||||
return fmt.Errorf("generate spec: %w", err)
|
|
||||||
}
|
|
||||||
tmpFile.Close()
|
|
||||||
specFile = tmpFile.Name()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gen := &goapi.SDKGenerator{
|
gen := &goapi.SDKGenerator{
|
||||||
SpecPath: specFile,
|
|
||||||
OutputDir: output,
|
OutputDir: output,
|
||||||
PackageName: packageName,
|
PackageName: packageName,
|
||||||
}
|
}
|
||||||
|
|
@ -61,18 +50,42 @@ func addSDKCommand(parent *cli.Command) {
|
||||||
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
|
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
|
||||||
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
|
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
|
||||||
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
|
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
|
||||||
return fmt.Errorf("openapi-generator-cli not installed")
|
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate for each language.
|
// If no spec file was provided, generate one only after confirming the
|
||||||
for l := range strings.SplitSeq(lang, ",") {
|
// generator is available.
|
||||||
l = strings.TrimSpace(l)
|
if specFile == "" {
|
||||||
if l == "" {
|
builder, err := sdkSpecBuilder(cfg)
|
||||||
continue
|
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)
|
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 fmt.Errorf("generate %s: %w", l, err)
|
return coreerr.E("sdk.Generate", "generate "+l, err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
||||||
}
|
}
|
||||||
|
|
@ -82,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, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
|
||||||
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
||||||
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to 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")
|
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
|
||||||
|
registerSpecBuilderFlags(cmd, &cfg)
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
|
||||||
|
return newSpecBuilder(specBuilderConfig{
|
||||||
|
title: cfg.title,
|
||||||
|
summary: cfg.summary,
|
||||||
|
description: cfg.description,
|
||||||
|
version: cfg.version,
|
||||||
|
swaggerPath: cfg.swaggerPath,
|
||||||
|
graphqlPath: cfg.graphqlPath,
|
||||||
|
graphqlPlayground: cfg.graphqlPlayground,
|
||||||
|
graphqlPlaygroundPath: cfg.graphqlPlaygroundPath,
|
||||||
|
ssePath: cfg.ssePath,
|
||||||
|
wsPath: cfg.wsPath,
|
||||||
|
pprofEnabled: cfg.pprofEnabled,
|
||||||
|
expvarEnabled: cfg.expvarEnabled,
|
||||||
|
cacheEnabled: cfg.cacheEnabled,
|
||||||
|
cacheTTL: cfg.cacheTTL,
|
||||||
|
cacheMaxEntries: cfg.cacheMaxEntries,
|
||||||
|
cacheMaxBytes: cfg.cacheMaxBytes,
|
||||||
|
i18nDefaultLocale: cfg.i18nDefaultLocale,
|
||||||
|
i18nSupportedLocales: cfg.i18nSupportedLocales,
|
||||||
|
authentikIssuer: cfg.authentikIssuer,
|
||||||
|
authentikClientID: cfg.authentikClientID,
|
||||||
|
authentikTrustedProxy: cfg.authentikTrustedProxy,
|
||||||
|
authentikPublicPaths: cfg.authentikPublicPaths,
|
||||||
|
termsURL: cfg.termsURL,
|
||||||
|
contactName: cfg.contactName,
|
||||||
|
contactURL: cfg.contactURL,
|
||||||
|
contactEmail: cfg.contactEmail,
|
||||||
|
licenseName: cfg.licenseName,
|
||||||
|
licenseURL: cfg.licenseURL,
|
||||||
|
externalDocsDescription: cfg.externalDocsDescription,
|
||||||
|
externalDocsURL: cfg.externalDocsURL,
|
||||||
|
servers: cfg.servers,
|
||||||
|
securitySchemes: cfg.securitySchemes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
|
||||||
|
return specGroupsIter(goapi.NewToolBridge("/tools"))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,52 +3,103 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
goapi "forge.lthn.ai/core/api"
|
goapi "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addSpecCommand(parent *cli.Command) {
|
func addSpecCommand(parent *cli.Command) {
|
||||||
var (
|
var (
|
||||||
output string
|
output string
|
||||||
format string
|
format string
|
||||||
title string
|
cfg specBuilderConfig
|
||||||
version string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cfg.title = "Lethean Core API"
|
||||||
|
cfg.description = "Lethean Core API"
|
||||||
|
cfg.version = "1.0.0"
|
||||||
|
|
||||||
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
||||||
// Build spec from registered route groups.
|
// Build spec from all route groups registered for CLI generation.
|
||||||
// Additional groups can be added here as the platform grows.
|
builder, err := newSpecBuilder(cfg)
|
||||||
builder := &goapi.SpecBuilder{
|
if err != nil {
|
||||||
Title: title,
|
return err
|
||||||
Description: "Lethean Core API",
|
|
||||||
Version: version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the default tool bridge — future versions will
|
|
||||||
// auto-populate from the MCP tool registry once the bridge
|
|
||||||
// integration lands in the local go-ai module.
|
|
||||||
bridge := goapi.NewToolBridge("/tools")
|
bridge := goapi.NewToolBridge("/tools")
|
||||||
groups := []goapi.RouteGroup{bridge}
|
groups := specGroupsIter(bridge)
|
||||||
|
|
||||||
if output != "" {
|
if output != "" {
|
||||||
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
|
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
||||||
return nil
|
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, &output, "output", "o", "", "Write spec to file instead of stdout")
|
||||||
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
||||||
cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec")
|
registerSpecBuilderFlags(cmd, &cfg)
|
||||||
cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec")
|
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseServers(raw string) []string {
|
||||||
|
return splitUniqueCSV(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSecuritySchemes(raw string) (map[string]any, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemes map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &schemes); err != nil {
|
||||||
|
return nil, cli.Err("invalid security schemes JSON: %w", err)
|
||||||
|
}
|
||||||
|
return schemes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) {
|
||||||
|
cli.StringFlag(cmd, &cfg.title, "title", "t", cfg.title, "API title in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.summary, "summary", "", cfg.summary, "OpenAPI info summary in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.description, "description", "d", cfg.description, "API description in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.version, "version", "V", cfg.version, "API version in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.swaggerPath, "swagger-path", "", "", "Swagger UI path in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.graphqlPath, "graphql-path", "", "", "GraphQL endpoint path in generated spec")
|
||||||
|
cli.BoolFlag(cmd, &cfg.graphqlPlayground, "graphql-playground", "", false, "Include the GraphQL playground endpoint in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.graphqlPlaygroundPath, "graphql-playground-path", "", "", "GraphQL playground path in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.ssePath, "sse-path", "", "", "SSE endpoint path in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.wsPath, "ws-path", "", "", "WebSocket endpoint path in generated spec")
|
||||||
|
cli.BoolFlag(cmd, &cfg.pprofEnabled, "pprof", "", false, "Include pprof endpoints in generated spec")
|
||||||
|
cli.BoolFlag(cmd, &cfg.expvarEnabled, "expvar", "", false, "Include expvar endpoint in generated spec")
|
||||||
|
cli.BoolFlag(cmd, &cfg.cacheEnabled, "cache", "", false, "Include cache metadata in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.cacheTTL, "cache-ttl", "", "", "Cache TTL in generated spec")
|
||||||
|
cli.IntFlag(cmd, &cfg.cacheMaxEntries, "cache-max-entries", "", 0, "Cache max entries in generated spec")
|
||||||
|
cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec")
|
||||||
|
cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.contactEmail, "contact-email", "", "", "OpenAPI contact email in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.licenseName, "license-name", "", "", "OpenAPI licence name in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.licenseURL, "license-url", "", "", "OpenAPI licence URL in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec")
|
||||||
|
cli.StringFlag(cmd, &cfg.servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
|
||||||
|
cli.StringFlag(cmd, &cfg.securitySchemes, "security-schemes", "", "", "JSON object of custom OpenAPI security schemes")
|
||||||
|
}
|
||||||
|
|
|
||||||
1314
cmd/api/cmd_test.go
1314
cmd/api/cmd_test.go
File diff suppressed because it is too large
Load diff
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)
|
||||||
|
}
|
||||||
66
codegen.go
66
codegen.go
|
|
@ -11,6 +11,10 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supported SDK target languages.
|
// Supported SDK target languages.
|
||||||
|
|
@ -29,6 +33,10 @@ var supportedLanguages = map[string]string{
|
||||||
}
|
}
|
||||||
|
|
||||||
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
// SDKGenerator wraps openapi-generator-cli for SDK generation.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// gen := &api.SDKGenerator{SpecPath: "./openapi.yaml", OutputDir: "./sdk", PackageName: "service"}
|
||||||
type SDKGenerator struct {
|
type SDKGenerator struct {
|
||||||
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
|
||||||
SpecPath string
|
SpecPath string
|
||||||
|
|
@ -42,19 +50,47 @@ type SDKGenerator struct {
|
||||||
|
|
||||||
// Generate creates an SDK for the given language using openapi-generator-cli.
|
// Generate creates an SDK for the given language using openapi-generator-cli.
|
||||||
// The language must be one of the supported languages returned by SupportedLanguages().
|
// The language must be one of the supported languages returned by SupportedLanguages().
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// err := gen.Generate(context.Background(), "go")
|
||||||
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
|
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
|
||||||
|
if g == nil {
|
||||||
|
return coreerr.E("SDKGenerator.Generate", "generator is nil", nil)
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
return coreerr.E("SDKGenerator.Generate", "context is nil", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
language = strings.TrimSpace(language)
|
||||||
generator, ok := supportedLanguages[language]
|
generator, ok := supportedLanguages[language]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unsupported language %q: supported languages are %v", language, SupportedLanguages())
|
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) {
|
specPath := strings.TrimSpace(g.SpecPath)
|
||||||
return fmt.Errorf("spec file not found: %s", 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 err := os.MkdirAll(outputDir, 0o755); err != nil {
|
if outputBase == "" {
|
||||||
return fmt.Errorf("create output directory: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := g.buildArgs(generator, outputDir)
|
args := g.buildArgs(generator, outputDir)
|
||||||
|
|
@ -63,7 +99,7 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("openapi-generator-cli failed for %s: %w", language, err)
|
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli failed for "+language, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -84,6 +120,12 @@ func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available checks if openapi-generator-cli is installed and accessible.
|
// Available checks if openapi-generator-cli is installed and accessible.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if !gen.Available() {
|
||||||
|
// t.Fatal("openapi-generator-cli is required")
|
||||||
|
// }
|
||||||
func (g *SDKGenerator) Available() bool {
|
func (g *SDKGenerator) Available() bool {
|
||||||
_, err := exec.LookPath("openapi-generator-cli")
|
_, err := exec.LookPath("openapi-generator-cli")
|
||||||
return err == nil
|
return err == nil
|
||||||
|
|
@ -91,11 +133,21 @@ func (g *SDKGenerator) Available() bool {
|
||||||
|
|
||||||
// SupportedLanguages returns the list of supported SDK target languages
|
// SupportedLanguages returns the list of supported SDK target languages
|
||||||
// in sorted order for deterministic output.
|
// in sorted order for deterministic output.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// langs := api.SupportedLanguages()
|
||||||
func SupportedLanguages() []string {
|
func SupportedLanguages() []string {
|
||||||
return slices.Sorted(maps.Keys(supportedLanguages))
|
return slices.Sorted(maps.Keys(supportedLanguages))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
|
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// for lang := range api.SupportedLanguagesIter() {
|
||||||
|
// fmt.Println(lang)
|
||||||
|
// }
|
||||||
func SupportedLanguagesIter() iter.Seq[string] {
|
func SupportedLanguagesIter() iter.Seq[string] {
|
||||||
return slices.Values(SupportedLanguages())
|
return slices.Values(SupportedLanguages())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
codegen_test.go
107
codegen_test.go
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── SDKGenerator tests ─────────────────────────────────────────────────────
|
// ── 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) {
|
func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
||||||
|
oldPath := os.Getenv("PATH")
|
||||||
|
|
||||||
|
// Provide a fake openapi-generator-cli so Generate reaches the exec step
|
||||||
|
// without depending on the host environment.
|
||||||
|
binDir := t.TempDir()
|
||||||
|
binPath := filepath.Join(binDir, "openapi-generator-cli")
|
||||||
|
script := []byte("#!/bin/sh\nexit 1\n")
|
||||||
|
if err := os.WriteFile(binPath, script, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to write fake generator: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath)
|
||||||
|
|
||||||
// Write a minimal spec file so we pass the file-exists check.
|
// Write a minimal spec file so we pass the file-exists check.
|
||||||
specDir := t.TempDir()
|
specDir := t.TempDir()
|
||||||
specPath := filepath.Join(specDir, "spec.json")
|
specPath := filepath.Join(specDir, "spec.json")
|
||||||
|
|
@ -73,8 +174,8 @@ func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
|
||||||
OutputDir: outputDir,
|
OutputDir: outputDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
|
// Generate will fail at the exec step, but the output directory should have
|
||||||
// but the output directory should have been created before that.
|
// been created before the CLI returned its non-zero status.
|
||||||
_ = gen.Generate(context.Background(), "go")
|
_ = gen.Generate(context.Background(), "go")
|
||||||
|
|
||||||
expected := filepath.Join(outputDir, "go")
|
expected := filepath.Join(outputDir, "go")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ type Engine struct {
|
||||||
swaggerTitle string
|
swaggerTitle string
|
||||||
swaggerDesc string
|
swaggerDesc string
|
||||||
swaggerVersion string
|
swaggerVersion string
|
||||||
|
swaggerExternalDocsDescription string
|
||||||
|
swaggerExternalDocsURL string
|
||||||
pprofEnabled bool
|
pprofEnabled bool
|
||||||
expvarEnabled bool
|
expvarEnabled bool
|
||||||
graphql *graphqlConfig
|
graphql *graphqlConfig
|
||||||
|
|
@ -128,6 +130,9 @@ type RouteDescription struct {
|
||||||
Summary string
|
Summary string
|
||||||
Description string
|
Description string
|
||||||
Tags []string
|
Tags []string
|
||||||
|
Deprecated bool
|
||||||
|
StatusCode int
|
||||||
|
Parameters []ParameterDescription
|
||||||
RequestBody map[string]any
|
RequestBody map[string]any
|
||||||
Response map[string]any
|
Response map[string]any
|
||||||
}
|
}
|
||||||
|
|
@ -151,12 +156,19 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
|
||||||
| `WithAddr(addr)` | Listen address | Default `:8080` |
|
| `WithAddr(addr)` | Listen address | Default `:8080` |
|
||||||
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
||||||
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
|
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
|
||||||
|
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
|
||||||
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
|
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
|
||||||
|
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests`; `X-RateLimit-*` on success; `Retry-After` on rejection; zero or negative disables |
|
||||||
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
|
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
|
||||||
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
|
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
|
||||||
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
|
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
|
||||||
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
|
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
|
||||||
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
|
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
|
||||||
|
| `WithSwaggerTermsOfService(url)` | OpenAPI terms of service metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
||||||
|
| `WithSwaggerContact(name, url, email)` | OpenAPI contact metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
||||||
|
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
|
||||||
|
| `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
|
||||||
|
| `WithSwaggerExternalDocs(description, url)` | OpenAPI external documentation metadata | Populates the top-level `externalDocs` block without manual `SpecBuilder` wiring |
|
||||||
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
|
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
|
||||||
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
|
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
|
||||||
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
|
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
|
||||||
|
|
@ -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 |
|
| `WithBrotli(level...)` | Brotli response compression | Writer pool for efficiency; default compression if level omitted |
|
||||||
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
|
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
|
||||||
| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout |
|
| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout |
|
||||||
| `WithCache(ttl)` | In-memory GET response caching | `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 |
|
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store |
|
||||||
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
|
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
|
||||||
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
|
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
|
||||||
|
|
@ -371,14 +384,19 @@ redirects and introspection). The GraphQL handler is created via gqlgen's
|
||||||
|
|
||||||
## 8. Response Caching
|
## 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.
|
- Only successful 2xx responses are cached.
|
||||||
- Non-GET methods pass through uncached.
|
- Non-GET methods pass through uncached.
|
||||||
- Cached responses are served with an `X-Cache: HIT` header.
|
- Cached responses are served with an `X-Cache: HIT` header.
|
||||||
- Expired entries are evicted lazily on the next access for the same key.
|
- Expired entries are evicted lazily on the next access for the same key.
|
||||||
- The cache is not shared across `Engine` instances.
|
- The cache is not shared across `Engine` instances.
|
||||||
- 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
|
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
|
||||||
capture the response body and status code for storage.
|
capture the response body and status code for storage.
|
||||||
|
|
@ -573,7 +591,9 @@ Generates an OpenAPI 3.1 specification from registered route groups.
|
||||||
| `--output` | `-o` | (stdout) | Write spec to file |
|
| `--output` | `-o` | (stdout) | Write spec to file |
|
||||||
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
|
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
|
||||||
| `--title` | `-t` | `Lethean Core API` | API title |
|
| `--title` | `-t` | `Lethean Core API` | API title |
|
||||||
|
| `--description` | `-d` | `Lethean Core API` | API description |
|
||||||
| `--version` | `-V` | `1.0.0` | API version |
|
| `--version` | `-V` | `1.0.0` | API version |
|
||||||
|
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
|
||||||
|
|
||||||
### `core api sdk`
|
### `core api sdk`
|
||||||
|
|
||||||
|
|
@ -585,6 +605,10 @@ Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`.
|
||||||
| `--output` | `-o` | `./sdk` | Output directory |
|
| `--output` | `-o` | `./sdk` | Output directory |
|
||||||
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
|
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
|
||||||
| `--package` | `-p` | `lethean` | Package name for generated SDK |
|
| `--package` | `-p` | `lethean` | Package name for generated SDK |
|
||||||
|
| `--title` | `-t` | `Lethean Core API` | API title in generated spec |
|
||||||
|
| `--description` | `-d` | `Lethean Core API` | API description in generated spec |
|
||||||
|
| `--version` | `-V` | `1.0.0` | API version in generated spec |
|
||||||
|
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,11 +169,12 @@ At the end of Phase 3, the module has 176 tests.
|
||||||
|
|
||||||
## Known Limitations
|
## 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
|
`WithCache(ttl, maxEntries, maxBytes)` can now bound the cache by entry count and approximate
|
||||||
total size bound. For a server receiving requests to many distinct URLs, the cache will grow
|
payload size, but it still stores responses in memory. Workloads with very large cached bodies
|
||||||
without bound. A LRU eviction policy or a configurable maximum is the natural next step.
|
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
|
### 2. SDK codegen requires an external binary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ func main() {
|
||||||
api.WithSecure(),
|
api.WithSecure(),
|
||||||
api.WithSlog(nil),
|
api.WithSlog(nil),
|
||||||
api.WithSwagger("My API", "A service description", "1.0.0"),
|
api.WithSwagger("My API", "A service description", "1.0.0"),
|
||||||
|
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
|
||||||
)
|
)
|
||||||
|
|
||||||
engine.Register(myRoutes) // any RouteGroup implementation
|
engine.Register(myRoutes) // any RouteGroup implementation
|
||||||
|
|
@ -94,7 +95,7 @@ engine.Register(&Routes{service: svc})
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
||||||
| `options.go` | All `With*()` option functions (25 options) |
|
| `options.go` | All `With*()` option functions (28 options) |
|
||||||
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
||||||
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
||||||
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
||||||
|
|
|
||||||
80
export.go
80
export.go
|
|
@ -6,51 +6,109 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"iter"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExportSpec generates the OpenAPI spec and writes it to w.
|
// ExportSpec generates the OpenAPI spec and writes it to w.
|
||||||
// Format must be "json" or "yaml".
|
// Format must be "json" or "yaml".
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups())
|
||||||
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
||||||
data, err := builder.Build(groups)
|
data, err := builder.Build(groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build spec: %w", err)
|
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":
|
case "json":
|
||||||
_, err = w.Write(data)
|
_, err := w.Write(data)
|
||||||
return err
|
return err
|
||||||
case "yaml":
|
case "yaml":
|
||||||
// Unmarshal JSON then re-marshal as YAML.
|
// Unmarshal JSON then re-marshal as YAML.
|
||||||
var obj any
|
var obj any
|
||||||
if err := json.Unmarshal(data, &obj); err != nil {
|
if err := json.Unmarshal(data, &obj); err != nil {
|
||||||
return fmt.Errorf("unmarshal spec: %w", err)
|
return coreerr.E(op, "unmarshal spec", err)
|
||||||
}
|
}
|
||||||
enc := yaml.NewEncoder(w)
|
enc := yaml.NewEncoder(w)
|
||||||
enc.SetIndent(2)
|
enc.SetIndent(2)
|
||||||
if err := enc.Encode(obj); err != nil {
|
if err := enc.Encode(obj); err != nil {
|
||||||
return fmt.Errorf("encode yaml: %w", err)
|
return coreerr.E(op, "encode yaml", err)
|
||||||
}
|
}
|
||||||
return enc.Close()
|
return enc.Close()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format %q: use \"json\" or \"yaml\"", format)
|
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.
|
// ExportSpecToFile writes the spec to the given path.
|
||||||
// The parent directory is created if it does not exist.
|
// The parent directory is created if it does not exist.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())
|
||||||
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
return exportSpecToFile(path, "ExportSpecToFile", func(w io.Writer) error {
|
||||||
return fmt.Errorf("create directory: %w", err)
|
return ExportSpec(w, format, builder, groups)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSpecToFileIter writes the OpenAPI spec from an iterator to the given path.
|
||||||
|
// The parent directory is created if it does not exist.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = api.ExportSpecToFileIter("./api/openapi.json", "json", builder, api.RegisteredSpecGroupsIter())
|
||||||
|
func ExportSpecToFileIter(path, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
|
||||||
|
return exportSpecToFile(path, "ExportSpecToFileIter", func(w io.Writer) error {
|
||||||
|
return ExportSpecIter(w, format, builder, groups)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportSpecToFile(path, op string, write func(io.Writer) error) (err error) {
|
||||||
|
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
|
||||||
|
return coreerr.E(op, "create directory", err)
|
||||||
}
|
}
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create file: %w", err)
|
return coreerr.E(op, "create file", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer func() {
|
||||||
return ExportSpec(f, format, builder, groups)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -14,7 +15,7 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── ExportSpec tests ─────────────────────────────────────────────────────
|
// ── 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) {
|
func TestExportSpec_Bad_InvalidFormat(t *testing.T) {
|
||||||
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
|
||||||
|
|
||||||
|
|
@ -164,3 +183,41 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
|
||||||
t.Fatal("expected /tools/metrics_query path in spec")
|
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"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Expvar runtime metrics endpoint ─────────────────────────────────
|
// ── 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
|
||||||
91
go.mod
91
go.mod
|
|
@ -1,10 +1,12 @@
|
||||||
module forge.lthn.ai/core/api
|
module dappco.re/go/core/api
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/cli v0.1.0
|
dappco.re/go/core/io v0.1.7
|
||||||
github.com/99designs/gqlgen v0.17.87
|
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/andybalholm/brotli v1.2.0
|
||||||
github.com/casbin/casbin/v2 v2.135.0
|
github.com/casbin/casbin/v2 v2.135.0
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
|
|
@ -20,64 +22,66 @@ require (
|
||||||
github.com/gin-contrib/slog v1.2.0
|
github.com/gin-contrib/slog v1.2.0
|
||||||
github.com/gin-contrib/static v1.1.5
|
github.com/gin-contrib/static v1.1.5
|
||||||
github.com/gin-contrib/timeout v1.1.0
|
github.com/gin-contrib/timeout v1.1.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.1
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
github.com/vektah/gqlparser/v2 v2.5.32
|
github.com/vektah/gqlparser/v2 v2.5.32
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
|
||||||
go.opentelemetry.io/otel v1.40.0
|
go.opentelemetry.io/otel v1.42.0
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0
|
go.opentelemetry.io/otel/sdk v1.42.0
|
||||||
go.opentelemetry.io/otel/trace v1.40.0
|
go.opentelemetry.io/otel/trace v1.42.0
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.35.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go v0.1.0 // indirect
|
dappco.re/go/core v0.3.2 // indirect
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0 // 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/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
|
||||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||||
github.com/go-openapi/spec v0.22.0 // indirect
|
github.com/go-openapi/spec v0.22.4 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
|
|
@ -91,34 +95,43 @@ require (
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/sosodev/duration v1.3.1 // indirect
|
github.com/sosodev/duration v1.4.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // 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
|
||||||
|
)
|
||||||
|
|
|
||||||
180
go.sum
180
go.sum
|
|
@ -1,15 +1,17 @@
|
||||||
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
|
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||||
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
|
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||||
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||||
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
|
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
|
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
|
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||||
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
|
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||||
github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg=
|
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
|
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=
|
||||||
|
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
|
||||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
|
@ -25,10 +27,10 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
|
@ -42,8 +44,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
|
@ -56,8 +58,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
|
|
@ -99,8 +99,8 @@ github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmn
|
||||||
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
|
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
|
||||||
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
|
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
|
||||||
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
|
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
|
@ -108,31 +108,33 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
|
@ -143,8 +145,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||||
|
|
@ -187,8 +189,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -216,8 +218,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
|
@ -252,54 +254,56 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
|
||||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
|
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
|
||||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
|
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
|
||||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -308,25 +312,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|
|
||||||
74
graphql.go
74
graphql.go
|
|
@ -4,6 +4,7 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
"github.com/99designs/gqlgen/graphql/handler"
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
|
|
@ -21,10 +22,61 @@ type graphqlConfig struct {
|
||||||
playground bool
|
playground bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GraphQLConfig captures the configured GraphQL endpoint settings for an Engine.
|
||||||
|
//
|
||||||
|
// It is intentionally small and serialisable so callers can inspect the active
|
||||||
|
// GraphQL surface without reaching into the internal handler configuration.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := api.GraphQLConfig{Enabled: true, Path: "/graphql", Playground: true}
|
||||||
|
type GraphQLConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Path string
|
||||||
|
Playground bool
|
||||||
|
PlaygroundPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphQLConfig returns the currently configured GraphQL settings for the engine.
|
||||||
|
//
|
||||||
|
// The result snapshots the Engine state at call time and normalises any configured
|
||||||
|
// URL path using the same rules as the runtime handlers.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := engine.GraphQLConfig()
|
||||||
|
func (e *Engine) GraphQLConfig() GraphQLConfig {
|
||||||
|
if e == nil {
|
||||||
|
return GraphQLConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := GraphQLConfig{
|
||||||
|
Enabled: e.graphql != nil,
|
||||||
|
Playground: e.graphql != nil && e.graphql.playground,
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.graphql != nil {
|
||||||
|
cfg.Path = normaliseGraphQLPath(e.graphql.path)
|
||||||
|
if e.graphql.playground {
|
||||||
|
cfg.PlaygroundPath = cfg.Path + "/playground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// GraphQLOption configures a GraphQL endpoint.
|
// GraphQLOption configures a GraphQL endpoint.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// opts := []api.GraphQLOption{api.WithPlayground(), api.WithGraphQLPath("/gql")}
|
||||||
type GraphQLOption func(*graphqlConfig)
|
type GraphQLOption func(*graphqlConfig)
|
||||||
|
|
||||||
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
|
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithGraphQL(schema, api.WithPlayground())
|
||||||
func WithPlayground() GraphQLOption {
|
func WithPlayground() GraphQLOption {
|
||||||
return func(cfg *graphqlConfig) {
|
return func(cfg *graphqlConfig) {
|
||||||
cfg.playground = true
|
cfg.playground = true
|
||||||
|
|
@ -33,9 +85,13 @@ func WithPlayground() GraphQLOption {
|
||||||
|
|
||||||
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
|
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
|
||||||
// The default path is "/graphql".
|
// The default path is "/graphql".
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithGraphQL(schema, api.WithGraphQLPath("/gql"))
|
||||||
func WithGraphQLPath(path string) GraphQLOption {
|
func WithGraphQLPath(path string) GraphQLOption {
|
||||||
return func(cfg *graphqlConfig) {
|
return func(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.
|
// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function.
|
||||||
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
|
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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"
|
||||||
"github.com/vektah/gqlparser/v2/ast"
|
"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 }
|
// 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) {
|
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|
|
||||||
84
group.go
84
group.go
|
|
@ -2,10 +2,18 @@
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// RouteGroup registers API routes onto a Gin router group.
|
// RouteGroup registers API routes onto a Gin router group.
|
||||||
// Subsystems implement this interface to declare their endpoints.
|
// Subsystems implement this interface to declare their endpoints.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var g api.RouteGroup = &myGroup{}
|
||||||
type RouteGroup interface {
|
type RouteGroup interface {
|
||||||
// Name returns a human-readable identifier for the group.
|
// Name returns a human-readable identifier for the group.
|
||||||
Name() string
|
Name() string
|
||||||
|
|
@ -18,6 +26,10 @@ type RouteGroup interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var sg api.StreamGroup = &myStreamGroup{}
|
||||||
type StreamGroup interface {
|
type StreamGroup interface {
|
||||||
// Channels returns the list of channel names this group streams on.
|
// Channels returns the list of channel names this group streams on.
|
||||||
Channels() []string
|
Channels() []string
|
||||||
|
|
@ -26,19 +38,89 @@ type StreamGroup interface {
|
||||||
// DescribableGroup extends RouteGroup with OpenAPI metadata.
|
// DescribableGroup extends RouteGroup with OpenAPI metadata.
|
||||||
// RouteGroups that implement this will have their endpoints
|
// RouteGroups that implement this will have their endpoints
|
||||||
// included in the generated OpenAPI specification.
|
// included in the generated OpenAPI specification.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var dg api.DescribableGroup = &myDescribableGroup{}
|
||||||
type DescribableGroup interface {
|
type DescribableGroup interface {
|
||||||
RouteGroup
|
RouteGroup
|
||||||
// Describe returns endpoint descriptions for OpenAPI generation.
|
// Describe returns endpoint descriptions for OpenAPI generation.
|
||||||
Describe() []RouteDescription
|
Describe() []RouteDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DescribableGroupIter extends DescribableGroup with an iterator-based
|
||||||
|
// description source for callers that want to avoid slice allocation.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var dg api.DescribableGroupIter = &myDescribableGroup{}
|
||||||
|
type DescribableGroupIter interface {
|
||||||
|
DescribableGroup
|
||||||
|
// DescribeIter returns endpoint descriptions for OpenAPI generation.
|
||||||
|
DescribeIter() iter.Seq[RouteDescription]
|
||||||
|
}
|
||||||
|
|
||||||
// RouteDescription describes a single endpoint for OpenAPI generation.
|
// RouteDescription describes a single endpoint for OpenAPI generation.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// rd := api.RouteDescription{
|
||||||
|
// Method: "POST",
|
||||||
|
// Path: "/users",
|
||||||
|
// Summary: "Create a user",
|
||||||
|
// Description: "Creates a new user account.",
|
||||||
|
// Tags: []string{"users"},
|
||||||
|
// StatusCode: 201,
|
||||||
|
// RequestBody: map[string]any{"type": "object"},
|
||||||
|
// Response: map[string]any{"type": "object"},
|
||||||
|
// }
|
||||||
type RouteDescription struct {
|
type RouteDescription struct {
|
||||||
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
|
||||||
Path string // Path relative to BasePath, e.g. "/generate"
|
Path string // Path relative to BasePath, e.g. "/generate"
|
||||||
Summary string // Short summary
|
Summary string // Short summary
|
||||||
Description string // Long description
|
Description string // Long description
|
||||||
Tags []string // OpenAPI tags for grouping
|
Tags []string // OpenAPI tags for grouping
|
||||||
|
// Hidden omits the route from generated documentation.
|
||||||
|
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)
|
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
|
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"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Stub implementations ────────────────────────────────────────────────
|
// ── Stub implementations ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WithGzip ──────────────────────────────────────────────────────────
|
// ── WithGzip ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"github.com/gin-contrib/httpsign/crypto"
|
"github.com/gin-contrib/httpsign/crypto"
|
||||||
"github.com/gin-gonic/gin"
|
"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"
|
const testSecretKey = "test-secret-key-for-hmac-sha256"
|
||||||
|
|
|
||||||
139
i18n.go
139
i18n.go
|
|
@ -3,6 +3,9 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
@ -13,7 +16,21 @@ const i18nContextKey = "i18n.locale"
|
||||||
// i18nMessagesKey is the Gin context key for the message lookup map.
|
// i18nMessagesKey is the Gin context key for the message lookup map.
|
||||||
const i18nMessagesKey = "i18n.messages"
|
const i18nMessagesKey = "i18n.messages"
|
||||||
|
|
||||||
|
// i18nCatalogKey is the Gin context key for the full locale->message catalog.
|
||||||
|
const i18nCatalogKey = "i18n.catalog"
|
||||||
|
|
||||||
|
// i18nDefaultLocaleKey stores the configured default locale for fallback lookups.
|
||||||
|
const i18nDefaultLocaleKey = "i18n.default_locale"
|
||||||
|
|
||||||
// I18nConfig configures the internationalisation middleware.
|
// I18nConfig configures the internationalisation middleware.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := api.I18nConfig{
|
||||||
|
// DefaultLocale: "en",
|
||||||
|
// Supported: []string{"en", "fr"},
|
||||||
|
// Messages: map[string]map[string]string{"fr": {"greeting": "Bonjour"}},
|
||||||
|
// }
|
||||||
type I18nConfig struct {
|
type I18nConfig struct {
|
||||||
// DefaultLocale is the fallback locale when the Accept-Language header
|
// DefaultLocale is the fallback locale when the Accept-Language header
|
||||||
// is absent or does not match any supported locale. Defaults to "en".
|
// is absent or does not match any supported locale. Defaults to "en".
|
||||||
|
|
@ -30,11 +47,32 @@ type I18nConfig struct {
|
||||||
Messages map[string]map[string]string
|
Messages map[string]map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I18nConfig returns the configured locale and message catalogue settings for
|
||||||
|
// the engine.
|
||||||
|
//
|
||||||
|
// The result snapshots the Engine state at call time and clones slices/maps so
|
||||||
|
// callers can safely reuse or modify the returned value.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cfg := engine.I18nConfig()
|
||||||
|
func (e *Engine) I18nConfig() I18nConfig {
|
||||||
|
if e == nil {
|
||||||
|
return I18nConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneI18nConfig(e.i18nConfig)
|
||||||
|
}
|
||||||
|
|
||||||
// WithI18n adds Accept-Language header parsing and locale detection middleware.
|
// WithI18n adds Accept-Language header parsing and locale detection middleware.
|
||||||
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
|
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
|
||||||
// with quality weighting support. The detected locale is stored in the Gin
|
// with quality weighting support. The detected locale is stored in the Gin
|
||||||
// context and can be retrieved by handlers via GetLocale().
|
// context and can be retrieved by handlers via GetLocale().
|
||||||
//
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithI18n(api.I18nConfig{Supported: []string{"en", "fr"}}))
|
||||||
|
//
|
||||||
// If messages are configured, handlers can look up localised strings via
|
// If messages are configured, handlers can look up localised strings via
|
||||||
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
|
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
|
||||||
// can replace the message map later.
|
// can replace the message map later.
|
||||||
|
|
@ -57,14 +95,16 @@ func WithI18n(cfg ...I18nConfig) Option {
|
||||||
tags = append(tags, tag)
|
tags = append(tags, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
snapshot := cloneI18nConfig(config)
|
||||||
|
e.i18nConfig = snapshot
|
||||||
matcher := language.NewMatcher(tags)
|
matcher := language.NewMatcher(tags)
|
||||||
|
|
||||||
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config))
|
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, snapshot))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
|
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
|
||||||
// it against supported locales, and stores the 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 {
|
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
accept := c.GetHeader("Accept-Language")
|
accept := c.GetHeader("Accept-Language")
|
||||||
|
|
@ -75,19 +115,17 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
} else {
|
} else {
|
||||||
tags, _, _ := language.ParseAcceptLanguage(accept)
|
tags, _, _ := language.ParseAcceptLanguage(accept)
|
||||||
tag, _, _ := matcher.Match(tags...)
|
tag, _, _ := matcher.Match(tags...)
|
||||||
base, _ := tag.Base()
|
locale = tag.String()
|
||||||
locale = base.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(i18nContextKey, locale)
|
c.Set(i18nContextKey, locale)
|
||||||
|
c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale)
|
||||||
|
|
||||||
// Attach the message map for this locale if messages are configured.
|
// Attach the message map for this locale if messages are configured.
|
||||||
if cfg.Messages != nil {
|
if cfg.Messages != nil {
|
||||||
|
c.Set(i18nCatalogKey, cfg.Messages)
|
||||||
if msgs, ok := cfg.Messages[locale]; ok {
|
if msgs, ok := cfg.Messages[locale]; ok {
|
||||||
c.Set(i18nMessagesKey, msgs)
|
c.Set(i18nMessagesKey, msgs)
|
||||||
} else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok {
|
|
||||||
// Fall back to default locale messages.
|
|
||||||
c.Set(i18nMessagesKey, msgs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +135,10 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
|
||||||
|
|
||||||
// GetLocale returns the detected locale for the current request.
|
// GetLocale returns the detected locale for the current request.
|
||||||
// Returns "en" if the i18n middleware was not applied.
|
// Returns "en" if the i18n middleware was not applied.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// locale := api.GetLocale(c)
|
||||||
func GetLocale(c *gin.Context) string {
|
func GetLocale(c *gin.Context) string {
|
||||||
if v, ok := c.Get(i18nContextKey); ok {
|
if v, ok := c.Get(i18nContextKey); ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
|
|
@ -109,6 +151,10 @@ func GetLocale(c *gin.Context) string {
|
||||||
// GetMessage looks up a localised message by key for the current request.
|
// GetMessage looks up a localised message by key for the current request.
|
||||||
// Returns the message string and true if found, or empty string and false
|
// Returns the message string and true if found, or empty string and false
|
||||||
// if the key does not exist or the i18n middleware was not applied.
|
// if the key does not exist or the i18n middleware was not applied.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// msg, ok := api.GetMessage(c, "greeting")
|
||||||
func GetMessage(c *gin.Context, key string) (string, bool) {
|
func GetMessage(c *gin.Context, key string) (string, bool) {
|
||||||
if v, ok := c.Get(i18nMessagesKey); ok {
|
if v, ok := c.Get(i18nMessagesKey); ok {
|
||||||
if msgs, ok := v.(map[string]string); ok {
|
if msgs, ok := v.(map[string]string); ok {
|
||||||
|
|
@ -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
|
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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── 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) {
|
func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
e, _ := api.New(
|
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)
|
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-contrib/location/v2"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
113
middleware.go
113
middleware.go
|
|
@ -5,20 +5,44 @@ package api
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// requestIDContextKey is the Gin context key used by requestIDMiddleware.
|
||||||
|
const requestIDContextKey = "request_id"
|
||||||
|
|
||||||
|
// requestStartContextKey stores when the request began so handlers can
|
||||||
|
// calculate elapsed duration for response metadata.
|
||||||
|
const requestStartContextKey = "request_start"
|
||||||
|
|
||||||
|
// recoveryMiddleware converts panics into a standard JSON error envelope.
|
||||||
|
// This keeps internal failures consistent with the rest of the framework
|
||||||
|
// and avoids Gin's default plain-text 500 response.
|
||||||
|
func recoveryMiddleware() gin.HandlerFunc {
|
||||||
|
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||||
|
fmt.Fprintf(gin.DefaultErrorWriter, "[Recovery] panic recovered: %v\n", recovered)
|
||||||
|
debug.PrintStack()
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, Fail(
|
||||||
|
"internal_server_error",
|
||||||
|
"Internal server error",
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
|
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
|
||||||
// Requests to paths in the skip list are allowed through without authentication.
|
// Requests to paths in the skip list are allowed through without authentication.
|
||||||
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
|
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
|
||||||
func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
|
func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Check whether the request path should bypass authentication.
|
// Check whether the request path should bypass authentication.
|
||||||
for _, path := range skip {
|
for _, path := range skip() {
|
||||||
if strings.HasPrefix(c.Request.URL.Path, path) {
|
if isPublicPath(c.Request.URL.Path, path) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
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.
|
// requestIDMiddleware ensures every response carries an X-Request-ID header.
|
||||||
// If the client sends one, it is preserved; otherwise a random 16-byte hex
|
// If the client sends one, it is preserved; otherwise a random 16-byte hex
|
||||||
// string is generated. The ID is also stored in the Gin context as "request_id".
|
// string is generated. The ID is also stored in the Gin context as "request_id".
|
||||||
func requestIDMiddleware() gin.HandlerFunc {
|
func requestIDMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
c.Set(requestStartContextKey, time.Now())
|
||||||
|
|
||||||
id := c.GetHeader("X-Request-ID")
|
id := c.GetHeader("X-Request-ID")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
|
|
@ -52,8 +102,63 @@ func requestIDMiddleware() gin.HandlerFunc {
|
||||||
id = hex.EncodeToString(b)
|
id = hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("request_id", id)
|
c.Set(requestIDContextKey, id)
|
||||||
c.Header("X-Request-ID", id)
|
c.Header("X-Request-ID", id)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRequestID returns the request ID assigned by requestIDMiddleware.
|
||||||
|
// Returns an empty string when the middleware was not applied.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// id := api.GetRequestID(c)
|
||||||
|
func GetRequestID(c *gin.Context) string {
|
||||||
|
if v, ok := c.Get(requestIDContextKey); ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestDuration returns the elapsed time since requestIDMiddleware started
|
||||||
|
// handling the request. Returns 0 when the middleware was not applied.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// d := api.GetRequestDuration(c)
|
||||||
|
func GetRequestDuration(c *gin.Context) time.Duration {
|
||||||
|
if v, ok := c.Get(requestStartContextKey); ok {
|
||||||
|
if started, ok := v.(time.Time); ok && !started.IsZero() {
|
||||||
|
return time.Since(started)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestMeta returns request metadata collected by requestIDMiddleware.
|
||||||
|
// The returned meta includes the request ID and elapsed duration when
|
||||||
|
// available. It returns nil when neither value is available.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// meta := api.GetRequestMeta(c)
|
||||||
|
func GetRequestMeta(c *gin.Context) *Meta {
|
||||||
|
meta := &Meta{}
|
||||||
|
|
||||||
|
if id := GetRequestID(c); id != "" {
|
||||||
|
meta.RequestID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration := GetRequestDuration(c); duration > 0 {
|
||||||
|
meta.Duration = duration.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.RequestID == "" && meta.Duration == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────────
|
// ── Bearer auth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
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 ──────────────────────────────────────────────────────────
|
// ── Request ID ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
|
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 ────────────────────────────────────────────────────────────────
|
// ── CORS ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
|
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ package api_test
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEngine_GroupsIter(t *testing.T) {
|
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 {
|
type streamGroupStub struct {
|
||||||
healthGroup
|
healthGroup
|
||||||
channels []string
|
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) {
|
func TestToolBridge_Iterators(t *testing.T) {
|
||||||
b := api.NewToolBridge("/tools")
|
b := api.NewToolBridge("/tools")
|
||||||
desc := api.ToolDescriptor{Name: "test", Group: "g1"}
|
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) {
|
func TestCodegen_SupportedLanguagesIter(t *testing.T) {
|
||||||
var langs []string
|
var langs []string
|
||||||
for l := range api.SupportedLanguagesIter() {
|
for l := range api.SupportedLanguagesIter() {
|
||||||
|
|
|
||||||
2106
openapi.go
2106
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"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
|
@ -26,9 +27,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Option configures an Engine during construction.
|
// Option configures an Engine during construction.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithAddr(":8080"))
|
||||||
type Option func(*Engine)
|
type Option func(*Engine)
|
||||||
|
|
||||||
// WithAddr sets the listen address for the server.
|
// WithAddr sets the listen address for the server.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithAddr(":8443"))
|
||||||
func WithAddr(addr string) Option {
|
func WithAddr(addr string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.addr = addr
|
e.addr = addr
|
||||||
|
|
@ -36,26 +45,57 @@ func WithAddr(addr string) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithBearerAuth adds bearer token authentication middleware.
|
// 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 {
|
func WithBearerAuth(token string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
skip := []string{"/health", "/swagger"}
|
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
|
||||||
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
|
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.
|
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
|
||||||
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
|
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithRequestID())
|
||||||
func WithRequestID() Option {
|
func WithRequestID() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, requestIDMiddleware())
|
e.middlewares = append(e.middlewares, requestIDMiddleware())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithResponseMeta attaches request metadata to JSON envelope responses.
|
||||||
|
// It preserves any existing pagination metadata and merges in request_id
|
||||||
|
// and duration when available from the request context. Combine it with
|
||||||
|
// WithRequestID() to populate both fields automatically.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithRequestID(), api.WithResponseMeta())
|
||||||
|
func WithResponseMeta() Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.middlewares = append(e.middlewares, responseMetaMiddleware())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
|
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
|
||||||
// Pass "*" to allow all origins, or supply specific origin URLs.
|
// Pass "*" to allow all origins, or supply specific origin URLs.
|
||||||
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
||||||
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
|
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithCORS("*"))
|
||||||
func WithCORS(allowOrigins ...string) Option {
|
func WithCORS(allowOrigins ...string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
cfg := cors.Config{
|
cfg := cors.Config{
|
||||||
|
|
@ -76,6 +116,10 @@ func WithCORS(allowOrigins ...string) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithMiddleware appends arbitrary Gin middleware to the engine.
|
// WithMiddleware appends arbitrary Gin middleware to the engine.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithMiddleware(loggingMiddleware))
|
||||||
func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, mw...)
|
e.middlewares = append(e.middlewares, mw...)
|
||||||
|
|
@ -85,6 +129,10 @@ func WithMiddleware(mw ...gin.HandlerFunc) Option {
|
||||||
// WithStatic serves static files from the given root directory at urlPrefix.
|
// WithStatic serves static files from the given root directory at urlPrefix.
|
||||||
// Directory listing is disabled; only individual files are served.
|
// Directory listing is disabled; only individual files are served.
|
||||||
// Internally this uses gin-contrib/static as Gin middleware.
|
// Internally this uses gin-contrib/static as Gin middleware.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithStatic("/assets", "./public"))
|
||||||
func WithStatic(urlPrefix, root string) Option {
|
func WithStatic(urlPrefix, root string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
|
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
|
||||||
|
|
@ -92,33 +140,215 @@ func WithStatic(urlPrefix, root string) Option {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithWSHandler registers a WebSocket handler at GET /ws.
|
// WithWSHandler registers a WebSocket handler at GET /ws.
|
||||||
|
// Use WithWSPath to customise the route before mounting the handler.
|
||||||
// Typically this wraps a go-ws Hub.Handler().
|
// Typically this wraps a go-ws Hub.Handler().
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithWSHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
|
||||||
func WithWSHandler(h http.Handler) Option {
|
func WithWSHandler(h http.Handler) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.wsHandler = h
|
e.wsHandler = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithAuthentik adds Authentik forward-auth middleware that extracts user
|
// WithWSPath sets a custom URL path for the WebSocket endpoint.
|
||||||
// identity from X-authentik-* headers set by a trusted reverse proxy.
|
// The default path is "/ws".
|
||||||
// The middleware is permissive: unauthenticated requests are allowed through.
|
//
|
||||||
func WithAuthentik(cfg AuthentikConfig) Option {
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithWSPath("/socket"))
|
||||||
|
func WithWSPath(path string) Option {
|
||||||
return func(e *Engine) {
|
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.
|
// The title, description, and version populate the OpenAPI info block.
|
||||||
|
// Use WithSwaggerSummary() to set the optional info.summary field.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSwagger("Service", "Public API", "1.0.0"))
|
||||||
func WithSwagger(title, description, version string) Option {
|
func WithSwagger(title, description, version string) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.swaggerTitle = title
|
e.swaggerTitle = strings.TrimSpace(title)
|
||||||
e.swaggerDesc = description
|
e.swaggerDesc = strings.TrimSpace(description)
|
||||||
e.swaggerVersion = version
|
e.swaggerVersion = strings.TrimSpace(version)
|
||||||
e.swaggerEnabled = true
|
e.swaggerEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSwaggerSummary adds the OpenAPI info.summary field to generated specs.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerSummary("Service overview")
|
||||||
|
func WithSwaggerSummary(summary string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if summary = strings.TrimSpace(summary); summary != "" {
|
||||||
|
e.swaggerSummary = summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerPath sets a custom URL path for the Swagger UI.
|
||||||
|
// The default path is "/swagger".
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSwaggerPath("/docs"))
|
||||||
|
func WithSwaggerPath(path string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.swaggerPath = normaliseSwaggerPath(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerTermsOfService adds the terms of service URL to the generated Swagger spec.
|
||||||
|
// Empty strings are ignored.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerTermsOfService("https://example.com/terms")
|
||||||
|
func WithSwaggerTermsOfService(url string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if url = strings.TrimSpace(url); url != "" {
|
||||||
|
e.swaggerTermsOfService = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerContact adds contact metadata to the generated Swagger spec.
|
||||||
|
// Empty fields are ignored. Multiple calls replace the previous contact data.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com")
|
||||||
|
func WithSwaggerContact(name, url, email string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
e.swaggerContactName = name
|
||||||
|
}
|
||||||
|
if url = strings.TrimSpace(url); url != "" {
|
||||||
|
e.swaggerContactURL = url
|
||||||
|
}
|
||||||
|
if email = strings.TrimSpace(email); email != "" {
|
||||||
|
e.swaggerContactEmail = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerServers adds OpenAPI server metadata to the generated Swagger spec.
|
||||||
|
// Empty strings are ignored. Multiple calls append and normalise the combined
|
||||||
|
// server list so callers can compose metadata across options.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerServers("https://api.example.com", "https://docs.example.com")
|
||||||
|
func WithSwaggerServers(servers ...string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.swaggerServers = normaliseServers(append(e.swaggerServers, servers...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerLicense adds licence metadata to the generated Swagger spec.
|
||||||
|
// Pass both a name and URL to populate the OpenAPI info block consistently.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/")
|
||||||
|
func WithSwaggerLicense(name, url string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
e.swaggerLicenseName = name
|
||||||
|
}
|
||||||
|
if url = strings.TrimSpace(url); url != "" {
|
||||||
|
e.swaggerLicenseURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerSecuritySchemes merges custom OpenAPI security schemes into the
|
||||||
|
// generated Swagger spec. Existing schemes are preserved unless the new map
|
||||||
|
// defines the same key, in which case the later definition wins.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerSecuritySchemes(map[string]any{
|
||||||
|
// "apiKeyAuth": map[string]any{
|
||||||
|
// "type": "apiKey",
|
||||||
|
// "in": "header",
|
||||||
|
// "name": "X-API-Key",
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
func WithSwaggerSecuritySchemes(schemes map[string]any) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if len(schemes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.swaggerSecuritySchemes == nil {
|
||||||
|
e.swaggerSecuritySchemes = make(map[string]any, len(schemes))
|
||||||
|
}
|
||||||
|
for name, scheme := range schemes {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || scheme == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.swaggerSecuritySchemes[name] = cloneOpenAPIValue(scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSwaggerExternalDocs adds top-level external documentation metadata to
|
||||||
|
// the generated Swagger spec.
|
||||||
|
// Empty URLs are ignored; the description is optional.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs")
|
||||||
|
func WithSwaggerExternalDocs(description, url string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
if description = strings.TrimSpace(description); description != "" {
|
||||||
|
e.swaggerExternalDocsDescription = description
|
||||||
|
}
|
||||||
|
if url = strings.TrimSpace(url); url != "" {
|
||||||
|
e.swaggerExternalDocsURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
|
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
|
||||||
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
|
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
|
||||||
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
|
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
|
||||||
|
|
@ -126,6 +356,10 @@ func WithSwagger(title, description, version string) Option {
|
||||||
//
|
//
|
||||||
// WARNING: pprof exposes sensitive runtime data and should only be
|
// WARNING: pprof exposes sensitive runtime data and should only be
|
||||||
// enabled in development or behind authentication in production.
|
// enabled in development or behind authentication in production.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithPprof())
|
||||||
func WithPprof() Option {
|
func WithPprof() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.pprofEnabled = true
|
e.pprofEnabled = true
|
||||||
|
|
@ -140,6 +374,10 @@ func WithPprof() Option {
|
||||||
// WARNING: expvar exposes runtime internals (memory allocation,
|
// WARNING: expvar exposes runtime internals (memory allocation,
|
||||||
// goroutine counts, command-line arguments) and should only be
|
// goroutine counts, command-line arguments) and should only be
|
||||||
// enabled in development or behind authentication in production.
|
// enabled in development or behind authentication in production.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithExpvar())
|
||||||
func WithExpvar() Option {
|
func WithExpvar() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.expvarEnabled = true
|
e.expvarEnabled = true
|
||||||
|
|
@ -151,6 +389,10 @@ func WithExpvar() Option {
|
||||||
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
|
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
|
||||||
// SSL redirect is not enabled so the middleware works behind a reverse proxy
|
// SSL redirect is not enabled so the middleware works behind a reverse proxy
|
||||||
// that terminates TLS.
|
// that terminates TLS.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSecure())
|
||||||
func WithSecure() Option {
|
func WithSecure() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, secure.New(secure.Config{
|
e.middlewares = append(e.middlewares, secure.New(secure.Config{
|
||||||
|
|
@ -167,6 +409,10 @@ func WithSecure() Option {
|
||||||
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
|
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
|
||||||
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
|
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
|
||||||
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
|
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithGzip())
|
||||||
func WithGzip(level ...int) Option {
|
func WithGzip(level ...int) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
l := gzip.DefaultCompression
|
l := gzip.DefaultCompression
|
||||||
|
|
@ -180,6 +426,10 @@ func WithGzip(level ...int) Option {
|
||||||
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
|
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
|
||||||
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
|
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
|
||||||
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
|
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithBrotli())
|
||||||
func WithBrotli(level ...int) Option {
|
func WithBrotli(level ...int) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
l := BrotliDefaultCompression
|
l := BrotliDefaultCompression
|
||||||
|
|
@ -193,6 +443,10 @@ func WithBrotli(level ...int) Option {
|
||||||
// WithSlog adds structured request logging middleware via gin-contrib/slog.
|
// WithSlog adds structured request logging middleware via gin-contrib/slog.
|
||||||
// Each request is logged with method, path, status code, latency, and client IP.
|
// Each request is logged with method, path, status code, latency, and client IP.
|
||||||
// If logger is nil, slog.Default() is used.
|
// If logger is nil, slog.Default() is used.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSlog(nil))
|
||||||
func WithSlog(logger *slog.Logger) Option {
|
func WithSlog(logger *slog.Logger) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
|
|
@ -214,8 +468,15 @@ func WithSlog(logger *slog.Logger) Option {
|
||||||
//
|
//
|
||||||
// A zero or negative duration effectively disables the timeout (the handler
|
// A zero or negative duration effectively disables the timeout (the handler
|
||||||
// runs without a deadline) — this is safe and will not panic.
|
// runs without a deadline) — this is safe and will not panic.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithTimeout(5 * time.Second))
|
||||||
func WithTimeout(d time.Duration) Option {
|
func WithTimeout(d time.Duration) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
e.middlewares = append(e.middlewares, timeout.New(
|
e.middlewares = append(e.middlewares, timeout.New(
|
||||||
timeout.WithTimeout(d),
|
timeout.WithTimeout(d),
|
||||||
timeout.WithResponse(timeoutResponse),
|
timeout.WithResponse(timeoutResponse),
|
||||||
|
|
@ -232,17 +493,77 @@ func timeoutResponse(c *gin.Context) {
|
||||||
// Successful (2xx) GET responses are cached for the given TTL and served
|
// Successful (2xx) GET responses are cached for the given TTL and served
|
||||||
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
|
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
|
||||||
// and error responses pass through uncached.
|
// and error responses pass through uncached.
|
||||||
func WithCache(ttl time.Duration) Option {
|
//
|
||||||
|
// Optional integer limits enable LRU eviction:
|
||||||
|
// - maxEntries limits the number of cached responses
|
||||||
|
// - maxBytes limits the approximate total cached payload size
|
||||||
|
//
|
||||||
|
// Pass a non-positive value to either limit to leave that dimension
|
||||||
|
// unbounded for backward compatibility. A non-positive TTL disables the
|
||||||
|
// middleware entirely.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithCache(5*time.Minute, 100, 10<<20))
|
||||||
|
func WithCache(ttl time.Duration, maxEntries ...int) Option {
|
||||||
|
entryLimit := 0
|
||||||
|
byteLimit := 0
|
||||||
|
if len(maxEntries) > 0 {
|
||||||
|
entryLimit = maxEntries[0]
|
||||||
|
}
|
||||||
|
if len(maxEntries) > 1 {
|
||||||
|
byteLimit = maxEntries[1]
|
||||||
|
}
|
||||||
|
return WithCacheLimits(ttl, entryLimit, byteLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCacheLimits adds in-memory response caching middleware for GET requests
|
||||||
|
// with explicit entry and payload-size bounds.
|
||||||
|
//
|
||||||
|
// This is the clearer form of WithCache when call sites want to make the
|
||||||
|
// eviction policy self-documenting.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
|
||||||
|
func WithCacheLimits(ttl time.Duration, maxEntries, maxBytes int) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
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))
|
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithRateLimit adds token-bucket rate limiting middleware.
|
||||||
|
// Requests are bucketed by API key or bearer token when present, and
|
||||||
|
// otherwise by client IP. Passing requests are annotated with
|
||||||
|
// X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
|
||||||
|
// Requests exceeding the configured limit are rejected with 429 Too Many
|
||||||
|
// Requests, Retry-After, and the standard Fail() error envelope.
|
||||||
|
// A zero or negative limit disables rate limiting.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithRateLimit(100))
|
||||||
|
func WithRateLimit(limit int) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.middlewares = append(e.middlewares, rateLimitMiddleware(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithSessions adds server-side session management middleware via
|
// WithSessions adds server-side session management middleware via
|
||||||
// gin-contrib/sessions using a cookie-based store. The name parameter
|
// gin-contrib/sessions using a cookie-based store. The name parameter
|
||||||
// sets the session cookie name (e.g. "session") and secret is the key
|
// sets the session cookie name (e.g. "session") and secret is the key
|
||||||
// used for cookie signing and encryption.
|
// used for cookie signing and encryption.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSessions("session", []byte("secret")))
|
||||||
func WithSessions(name string, secret []byte) Option {
|
func WithSessions(name string, secret []byte) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
store := cookie.NewStore(secret)
|
store := cookie.NewStore(secret)
|
||||||
|
|
@ -255,6 +576,10 @@ func WithSessions(name string, secret []byte) Option {
|
||||||
// holding the desired model and policy rules. The middleware extracts the
|
// holding the desired model and policy rules. The middleware extracts the
|
||||||
// subject from HTTP Basic Authentication, evaluates it against the request
|
// subject from HTTP Basic Authentication, evaluates it against the request
|
||||||
// method and path, and returns 403 Forbidden when the policy denies access.
|
// method and path, and returns 403 Forbidden when the policy denies access.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithAuthz(enforcer))
|
||||||
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
||||||
|
|
@ -274,6 +599,10 @@ func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||||
//
|
//
|
||||||
// Requests with a missing, malformed, or invalid signature are rejected with
|
// Requests with a missing, malformed, or invalid signature are rejected with
|
||||||
// 401 Unauthorised or 400 Bad Request.
|
// 401 Unauthorised or 400 Bad Request.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithHTTPSign(secrets))
|
||||||
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
auth := httpsign.NewAuthenticator(secrets, opts...)
|
auth := httpsign.NewAuthenticator(secrets, opts...)
|
||||||
|
|
@ -281,16 +610,34 @@ func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSSE registers a Server-Sent Events broker at GET /events.
|
// WithSSE registers a Server-Sent Events broker at the configured path.
|
||||||
// Clients connect to the endpoint and receive a streaming text/event-stream
|
// By default the endpoint is mounted at GET /events; use WithSSEPath to
|
||||||
// response. The broker manages client connections and broadcasts events
|
// 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.
|
// published via its Publish method.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// broker := api.NewSSEBroker()
|
||||||
|
// engine, _ := api.New(api.WithSSE(broker))
|
||||||
func WithSSE(broker *SSEBroker) Option {
|
func WithSSE(broker *SSEBroker) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.sseBroker = broker
|
e.sseBroker = broker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSSEPath sets a custom URL path for the SSE endpoint.
|
||||||
|
// The default path is "/events".
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// api.New(api.WithSSEPath("/stream"))
|
||||||
|
func WithSSEPath(path string) Option {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.ssePath = normaliseSSEPath(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithLocation adds reverse proxy header detection middleware via
|
// WithLocation adds reverse proxy header detection middleware via
|
||||||
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
|
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
|
||||||
// headers to determine the original scheme and host when the server runs
|
// headers to determine the original scheme and host when the server runs
|
||||||
|
|
@ -298,6 +645,10 @@ func WithSSE(broker *SSEBroker) Option {
|
||||||
//
|
//
|
||||||
// After this middleware runs, handlers can call location.Get(c) to retrieve
|
// After this middleware runs, handlers can call location.Get(c) to retrieve
|
||||||
// a *url.URL with the detected scheme, host, and base path.
|
// a *url.URL with the detected scheme, host, and base path.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithLocation())
|
||||||
func WithLocation() Option {
|
func WithLocation() Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
e.middlewares = append(e.middlewares, location.Default())
|
e.middlewares = append(e.middlewares, location.Default())
|
||||||
|
|
@ -311,6 +662,10 @@ func WithLocation() Option {
|
||||||
// api.New(
|
// api.New(
|
||||||
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
|
||||||
// )
|
// )
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// engine, _ := api.New(api.WithGraphQL(schema))
|
||||||
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
|
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
|
||||||
return func(e *Engine) {
|
return func(e *Engine) {
|
||||||
cfg := &graphqlConfig{
|
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.
|
// Package provider defines the Service Provider Framework interfaces.
|
||||||
//
|
//
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/api"
|
"dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider extends RouteGroup with a provider identity.
|
// Provider extends RouteGroup with a provider identity.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,168 @@
|
||||||
// SPDX-Licence-Identifier: EUPL-1.2
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package provider
|
package provider
|
||||||
|
|
||||||
// ProxyProvider will wrap polyglot (PHP/TS) providers that publish an OpenAPI
|
import (
|
||||||
// spec and run their own HTTP handler. The Go API layer reverse-proxies to
|
"net/http"
|
||||||
// their endpoint.
|
"net/http/httputil"
|
||||||
//
|
"net/url"
|
||||||
// This is a Phase 3 feature. The type is declared here as a forward reference
|
"strings"
|
||||||
// so the package structure is established.
|
|
||||||
//
|
coreapi "dappco.re/go/core/api"
|
||||||
// See the design spec SS Polyglot Providers for the full ProxyProvider contract.
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxyConfig configures a ProxyProvider that reverse-proxies to an upstream
|
||||||
|
// process (typically a runtime provider binary listening on 127.0.0.1).
|
||||||
|
type ProxyConfig struct {
|
||||||
|
// Name is the provider identity, e.g. "cool-widget".
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// BasePath is the API route prefix, e.g. "/api/v1/cool-widget".
|
||||||
|
BasePath string
|
||||||
|
|
||||||
|
// Upstream is the full URL of the upstream process,
|
||||||
|
// e.g. "http://127.0.0.1:9901".
|
||||||
|
Upstream string
|
||||||
|
|
||||||
|
// Element describes the custom element for GUI rendering.
|
||||||
|
// Leave zero-value if the provider has no UI.
|
||||||
|
Element ElementSpec
|
||||||
|
|
||||||
|
// SpecFile is the filesystem path to the provider's OpenAPI spec.
|
||||||
|
// Used by the Swagger aggregator. Leave empty if none.
|
||||||
|
SpecFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyProvider reverse-proxies requests to an upstream HTTP process.
|
||||||
|
// It implements Provider and Renderable so it integrates with the
|
||||||
|
// service provider framework and GUI discovery.
|
||||||
|
type ProxyProvider struct {
|
||||||
|
config ProxyConfig
|
||||||
|
proxy *httputil.ReverseProxy
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxy creates a ProxyProvider from the given configuration.
|
||||||
|
// Invalid upstream URLs do not panic; the provider retains the
|
||||||
|
// configuration error and responds with a standard 500 envelope when
|
||||||
|
// mounted. This keeps provider construction safe for callers.
|
||||||
|
func NewProxy(cfg ProxyConfig) *ProxyProvider {
|
||||||
|
target, err := url.Parse(cfg.Upstream)
|
||||||
|
if err != nil {
|
||||||
|
return &ProxyProvider{
|
||||||
|
config: cfg,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
|
||||||
|
// Preserve the original Director but strip the base path so the
|
||||||
|
// upstream receives clean paths (e.g. /items instead of /api/v1/cool-widget/items).
|
||||||
|
defaultDirector := proxy.Director
|
||||||
|
basePath := strings.TrimSuffix(cfg.BasePath, "/")
|
||||||
|
|
||||||
|
proxy.Director = func(req *http.Request) {
|
||||||
|
defaultDirector(req)
|
||||||
|
// Strip the base path prefix from the request path.
|
||||||
|
req.URL.Path = stripBasePath(req.URL.Path, basePath)
|
||||||
|
if req.URL.RawPath != "" {
|
||||||
|
req.URL.RawPath = stripBasePath(req.URL.RawPath, basePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProxyProvider{
|
||||||
|
config: cfg,
|
||||||
|
proxy: proxy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasePath returns the API route prefix.
|
||||||
|
func (p *ProxyProvider) BasePath() string {
|
||||||
|
return p.config.BasePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
var w http.ResponseWriter = c.Writer
|
||||||
|
if uw, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok {
|
||||||
|
w = uw.Unwrap()
|
||||||
|
}
|
||||||
|
p.proxy.ServeHTTP(w, c.Request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element returns the custom element specification for GUI rendering.
|
||||||
|
func (p *ProxyProvider) Element() ElementSpec {
|
||||||
|
return p.config.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecFile returns the path to the provider's OpenAPI spec file.
|
||||||
|
func (p *ProxyProvider) SpecFile() string {
|
||||||
|
return p.config.SpecFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream returns the upstream URL string.
|
||||||
|
func (p *ProxyProvider) Upstream() string {
|
||||||
|
return p.config.Upstream
|
||||||
|
}
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
pkg/provider/proxy_test.go
Normal file
214
pkg/provider/proxy_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package provider_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"dappco.re/go/core/api"
|
||||||
|
"dappco.re/go/core/api/pkg/provider"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- ProxyProvider tests ------------------------------------------------------
|
||||||
|
|
||||||
|
func TestProxyProvider_Name_Good(t *testing.T) {
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "cool-widget",
|
||||||
|
BasePath: "/api/v1/cool-widget",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
})
|
||||||
|
assert.Equal(t, "cool-widget", p.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_BasePath_Good(t *testing.T) {
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "cool-widget",
|
||||||
|
BasePath: "/api/v1/cool-widget",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
})
|
||||||
|
assert.Equal(t, "/api/v1/cool-widget", p.BasePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_Element_Good(t *testing.T) {
|
||||||
|
elem := provider.ElementSpec{
|
||||||
|
Tag: "core-cool-widget",
|
||||||
|
Source: "/assets/cool-widget.js",
|
||||||
|
}
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "cool-widget",
|
||||||
|
BasePath: "/api/v1/cool-widget",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
Element: elem,
|
||||||
|
})
|
||||||
|
assert.Equal(t, "core-cool-widget", p.Element().Tag)
|
||||||
|
assert.Equal(t, "/assets/cool-widget.js", p.Element().Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_SpecFile_Good(t *testing.T) {
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "cool-widget",
|
||||||
|
BasePath: "/api/v1/cool-widget",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
SpecFile: "/tmp/openapi.json",
|
||||||
|
})
|
||||||
|
assert.Equal(t, "/tmp/openapi.json", p.SpecFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_Proxy_Good(t *testing.T) {
|
||||||
|
// Start a test upstream server.
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]string{
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
// Create proxy provider pointing to the test server.
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "test-proxy",
|
||||||
|
BasePath: "/api/v1/test-proxy",
|
||||||
|
Upstream: upstream.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mount on an api.Engine.
|
||||||
|
engine, err := api.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
engine.Register(p)
|
||||||
|
|
||||||
|
handler := engine.Handler()
|
||||||
|
|
||||||
|
// Send a request through the proxy.
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/test-proxy/items", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
err = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The upstream should see the path with base path stripped.
|
||||||
|
assert.Equal(t, "/items", body["path"])
|
||||||
|
assert.Equal(t, "GET", body["method"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_ProxyRoot_Good(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := map[string]string{"path": r.URL.Path}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "test-proxy",
|
||||||
|
BasePath: "/api/v1/test-proxy",
|
||||||
|
Upstream: upstream.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
engine, err := api.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
engine.Register(p)
|
||||||
|
|
||||||
|
handler := engine.Handler()
|
||||||
|
|
||||||
|
// Request to the base path itself (root of the provider).
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/test-proxy/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
err = json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/", body["path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_HealthPassthrough_Good(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/health" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "health-test",
|
||||||
|
BasePath: "/api/v1/health-test",
|
||||||
|
Upstream: upstream.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
engine, err := api.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
engine.Register(p)
|
||||||
|
|
||||||
|
handler := engine.Handler()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/health-test/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), `"status":"ok"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_Renderable_Good(t *testing.T) {
|
||||||
|
// Verify ProxyProvider satisfies Renderable via the Registry.
|
||||||
|
p := provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "renderable-proxy",
|
||||||
|
BasePath: "/api/v1/renderable",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
Element: provider.ElementSpec{Tag: "core-test-panel", Source: "/assets/test.js"},
|
||||||
|
})
|
||||||
|
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(p)
|
||||||
|
|
||||||
|
renderables := reg.Renderable()
|
||||||
|
require.Len(t, renderables, 1)
|
||||||
|
assert.Equal(t, "core-test-panel", renderables[0].Element().Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
|
||||||
|
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
|
package provider
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"forge.lthn.ai/core/api"
|
"dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Registry collects providers and mounts them on an api.Engine.
|
// Registry collects providers and mounts them on an api.Engine.
|
||||||
|
|
@ -88,6 +88,24 @@ func (r *Registry) Streamable() []Streamable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamableIter returns an iterator over all registered providers that
|
||||||
|
// implement the Streamable interface.
|
||||||
|
func (r *Registry) StreamableIter() iter.Seq[Streamable] {
|
||||||
|
r.mu.RLock()
|
||||||
|
providers := slices.Clone(r.providers)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
return func(yield func(Streamable) bool) {
|
||||||
|
for _, p := range providers {
|
||||||
|
if s, ok := p.(Streamable); ok {
|
||||||
|
if !yield(s) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Describable returns all providers that implement the Describable interface.
|
// Describable returns all providers that implement the Describable interface.
|
||||||
func (r *Registry) Describable() []Describable {
|
func (r *Registry) Describable() []Describable {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
@ -101,6 +119,24 @@ func (r *Registry) Describable() []Describable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DescribableIter returns an iterator over all registered providers that
|
||||||
|
// implement the Describable interface.
|
||||||
|
func (r *Registry) DescribableIter() iter.Seq[Describable] {
|
||||||
|
r.mu.RLock()
|
||||||
|
providers := slices.Clone(r.providers)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
return func(yield func(Describable) bool) {
|
||||||
|
for _, p := range providers {
|
||||||
|
if d, ok := p.(Describable); ok {
|
||||||
|
if !yield(d) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Renderable returns all providers that implement the Renderable interface.
|
// Renderable returns all providers that implement the Renderable interface.
|
||||||
func (r *Registry) Renderable() []Renderable {
|
func (r *Registry) Renderable() []Renderable {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
@ -114,12 +150,32 @@ func (r *Registry) Renderable() []Renderable {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderableIter returns an iterator over all registered providers that
|
||||||
|
// implement the Renderable interface.
|
||||||
|
func (r *Registry) RenderableIter() iter.Seq[Renderable] {
|
||||||
|
r.mu.RLock()
|
||||||
|
providers := slices.Clone(r.providers)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
return func(yield func(Renderable) bool) {
|
||||||
|
for _, p := range providers {
|
||||||
|
if rv, ok := p.(Renderable); ok {
|
||||||
|
if !yield(rv) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProviderInfo is a serialisable summary of a registered provider.
|
// ProviderInfo is a serialisable summary of a registered provider.
|
||||||
type ProviderInfo struct {
|
type ProviderInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BasePath string `json:"basePath"`
|
BasePath string `json:"basePath"`
|
||||||
Channels []string `json:"channels,omitempty"`
|
Channels []string `json:"channels,omitempty"`
|
||||||
Element *ElementSpec `json:"element,omitempty"`
|
Element *ElementSpec `json:"element,omitempty"`
|
||||||
|
SpecFile string `json:"specFile,omitempty"`
|
||||||
|
Upstream string `json:"upstream,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info returns a summary of all registered providers.
|
// Info returns a summary of all registered providers.
|
||||||
|
|
@ -140,7 +196,76 @@ func (r *Registry) Info() []ProviderInfo {
|
||||||
elem := rv.Element()
|
elem := rv.Element()
|
||||||
info.Element = &elem
|
info.Element = &elem
|
||||||
}
|
}
|
||||||
|
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
||||||
|
info.SpecFile = sf.SpecFile()
|
||||||
|
}
|
||||||
|
if up, ok := p.(interface{ Upstream() string }); ok {
|
||||||
|
info.Upstream = up.Upstream()
|
||||||
|
}
|
||||||
infos = append(infos, info)
|
infos = append(infos, info)
|
||||||
}
|
}
|
||||||
return infos
|
return infos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InfoIter returns an iterator over all registered provider summaries.
|
||||||
|
// The iterator snapshots the current registry contents so callers can range
|
||||||
|
// over it without holding the registry lock.
|
||||||
|
func (r *Registry) InfoIter() iter.Seq[ProviderInfo] {
|
||||||
|
r.mu.RLock()
|
||||||
|
providers := slices.Clone(r.providers)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
return func(yield func(ProviderInfo) bool) {
|
||||||
|
for _, p := range providers {
|
||||||
|
info := ProviderInfo{
|
||||||
|
Name: p.Name(),
|
||||||
|
BasePath: p.BasePath(),
|
||||||
|
}
|
||||||
|
if s, ok := p.(Streamable); ok {
|
||||||
|
info.Channels = s.Channels()
|
||||||
|
}
|
||||||
|
if rv, ok := p.(Renderable); ok {
|
||||||
|
elem := rv.Element()
|
||||||
|
info.Element = &elem
|
||||||
|
}
|
||||||
|
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
||||||
|
info.SpecFile = sf.SpecFile()
|
||||||
|
}
|
||||||
|
if up, ok := p.(interface{ Upstream() string }); ok {
|
||||||
|
info.Upstream = up.Upstream()
|
||||||
|
}
|
||||||
|
if !yield(info) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecFiles returns all non-empty provider OpenAPI spec file paths.
|
||||||
|
// The result is deduplicated and sorted for stable discovery output.
|
||||||
|
func (r *Registry) SpecFiles() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
files := make(map[string]struct{}, len(r.providers))
|
||||||
|
for _, p := range r.providers {
|
||||||
|
if sf, ok := p.(interface{ SpecFile() string }); ok {
|
||||||
|
if path := sf.SpecFile(); path != "" {
|
||||||
|
files[path] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(files))
|
||||||
|
for path := range files {
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpecFilesIter returns an iterator over all non-empty provider OpenAPI spec files.
|
||||||
|
func (r *Registry) SpecFilesIter() iter.Seq[string] {
|
||||||
|
return slices.Values(r.SpecFiles())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ package provider_test
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/api"
|
"dappco.re/go/core/api"
|
||||||
"forge.lthn.ai/core/api/pkg/provider"
|
"dappco.re/go/core/api/pkg/provider"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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"}
|
return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type specFileProvider struct {
|
||||||
|
stubProvider
|
||||||
|
specFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *specFileProvider) SpecFile() string { return s.specFile }
|
||||||
|
|
||||||
type fullProvider struct {
|
type fullProvider struct {
|
||||||
streamableProvider
|
streamableProvider
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +119,36 @@ func TestRegistry_Streamable_Good(t *testing.T) {
|
||||||
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
|
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistry_StreamableIter_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&stubProvider{})
|
||||||
|
reg.Add(&streamableProvider{})
|
||||||
|
|
||||||
|
var streamables []provider.Streamable
|
||||||
|
for s := range reg.StreamableIter() {
|
||||||
|
streamables = append(streamables, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, streamables, 1)
|
||||||
|
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_StreamableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&streamableProvider{})
|
||||||
|
|
||||||
|
iter := reg.StreamableIter()
|
||||||
|
reg.Add(&streamableProvider{})
|
||||||
|
|
||||||
|
var streamables []provider.Streamable
|
||||||
|
for s := range iter {
|
||||||
|
streamables = append(streamables, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, streamables, 1)
|
||||||
|
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistry_Describable_Good(t *testing.T) {
|
func TestRegistry_Describable_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{}) // not describable
|
reg.Add(&stubProvider{}) // not describable
|
||||||
|
|
@ -122,6 +159,36 @@ func TestRegistry_Describable_Good(t *testing.T) {
|
||||||
assert.Len(t, d[0].Describe(), 1)
|
assert.Len(t, d[0].Describe(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistry_DescribableIter_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&stubProvider{})
|
||||||
|
reg.Add(&describableProvider{})
|
||||||
|
|
||||||
|
var describables []provider.Describable
|
||||||
|
for d := range reg.DescribableIter() {
|
||||||
|
describables = append(describables, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, describables, 1)
|
||||||
|
assert.Len(t, describables[0].Describe(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_DescribableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&describableProvider{})
|
||||||
|
|
||||||
|
iter := reg.DescribableIter()
|
||||||
|
reg.Add(&describableProvider{})
|
||||||
|
|
||||||
|
var describables []provider.Describable
|
||||||
|
for d := range iter {
|
||||||
|
describables = append(describables, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, describables, 1)
|
||||||
|
assert.Len(t, describables[0].Describe(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistry_Renderable_Good(t *testing.T) {
|
func TestRegistry_Renderable_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{}) // not renderable
|
reg.Add(&stubProvider{}) // not renderable
|
||||||
|
|
@ -132,6 +199,36 @@ func TestRegistry_Renderable_Good(t *testing.T) {
|
||||||
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
|
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistry_RenderableIter_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&stubProvider{})
|
||||||
|
reg.Add(&renderableProvider{})
|
||||||
|
|
||||||
|
var renderables []provider.Renderable
|
||||||
|
for r := range reg.RenderableIter() {
|
||||||
|
renderables = append(renderables, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, renderables, 1)
|
||||||
|
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_RenderableIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&renderableProvider{})
|
||||||
|
|
||||||
|
iter := reg.RenderableIter()
|
||||||
|
reg.Add(&renderableProvider{})
|
||||||
|
|
||||||
|
var renderables []provider.Renderable
|
||||||
|
for r := range iter {
|
||||||
|
renderables = append(renderables, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, renderables, 1)
|
||||||
|
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistry_Info_Good(t *testing.T) {
|
func TestRegistry_Info_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&fullProvider{})
|
reg.Add(&fullProvider{})
|
||||||
|
|
@ -147,6 +244,59 @@ func TestRegistry_Info_Good(t *testing.T) {
|
||||||
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Info_Good_ProxyMetadata(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(provider.NewProxy(provider.ProxyConfig{
|
||||||
|
Name: "proxy",
|
||||||
|
BasePath: "/api/proxy",
|
||||||
|
Upstream: "http://127.0.0.1:9999",
|
||||||
|
SpecFile: "/tmp/proxy-openapi.json",
|
||||||
|
}))
|
||||||
|
|
||||||
|
infos := reg.Info()
|
||||||
|
require.Len(t, infos, 1)
|
||||||
|
|
||||||
|
info := infos[0]
|
||||||
|
assert.Equal(t, "proxy", info.Name)
|
||||||
|
assert.Equal(t, "/api/proxy", info.BasePath)
|
||||||
|
assert.Equal(t, "/tmp/proxy-openapi.json", info.SpecFile)
|
||||||
|
assert.Equal(t, "http://127.0.0.1:9999", info.Upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_InfoIter_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&fullProvider{})
|
||||||
|
|
||||||
|
var infos []provider.ProviderInfo
|
||||||
|
for info := range reg.InfoIter() {
|
||||||
|
infos = append(infos, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, infos, 1)
|
||||||
|
info := infos[0]
|
||||||
|
assert.Equal(t, "full", info.Name)
|
||||||
|
assert.Equal(t, "/api/full", info.BasePath)
|
||||||
|
assert.Equal(t, []string{"stub.event"}, info.Channels)
|
||||||
|
require.NotNil(t, info.Element)
|
||||||
|
assert.Equal(t, "core-full-panel", info.Element.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_InfoIter_Good_SnapshotCurrentProviders(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&fullProvider{})
|
||||||
|
|
||||||
|
iter := reg.InfoIter()
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/later.json"})
|
||||||
|
|
||||||
|
var infos []provider.ProviderInfo
|
||||||
|
for info := range iter {
|
||||||
|
infos = append(infos, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, infos, 1)
|
||||||
|
assert.Equal(t, "full", infos[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistry_Iter_Good(t *testing.T) {
|
func TestRegistry_Iter_Good(t *testing.T) {
|
||||||
reg := provider.NewRegistry()
|
reg := provider.NewRegistry()
|
||||||
reg.Add(&stubProvider{})
|
reg.Add(&stubProvider{})
|
||||||
|
|
@ -158,3 +308,27 @@ func TestRegistry_Iter_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
assert.Equal(t, 2, count)
|
assert.Equal(t, 2, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistry_SpecFiles_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&stubProvider{})
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/b.json"})
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
|
||||||
|
reg.Add(&specFileProvider{specFile: ""})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"/tmp/a.yaml", "/tmp/b.json"}, reg.SpecFiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_SpecFilesIter_Good(t *testing.T) {
|
||||||
|
reg := provider.NewRegistry()
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/z.json"})
|
||||||
|
reg.Add(&specFileProvider{specFile: "/tmp/x.json"})
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for file := range reg.SpecFilesIter() {
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"/tmp/x.json", "/tmp/z.json"}, files)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Pprof profiling endpoints ─────────────────────────────────────────
|
// ── 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
|
package api
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
// Response is the standard envelope for all API responses.
|
// Response is the standard envelope for all API responses.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// resp := api.OK(map[string]any{"id": 42})
|
||||||
|
// resp.Success // true
|
||||||
type Response[T any] struct {
|
type Response[T any] struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Data T `json:"data,omitempty"`
|
Data T `json:"data,omitempty"`
|
||||||
|
|
@ -11,6 +18,10 @@ type Response[T any] struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error describes a failed API request.
|
// Error describes a failed API request.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// err := api.Error{Code: "invalid_input", Message: "Name is required"}
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -18,6 +29,10 @@ type Error struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta carries pagination and request metadata.
|
// Meta carries pagination and request metadata.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// meta := api.Meta{RequestID: "req_123", Duration: "12ms"}
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
RequestID string `json:"request_id,omitempty"`
|
RequestID string `json:"request_id,omitempty"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Duration string `json:"duration,omitempty"`
|
||||||
|
|
@ -27,6 +42,10 @@ type Meta struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OK wraps data in a successful response envelope.
|
// OK wraps data in a successful response envelope.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// c.JSON(http.StatusOK, api.OK(map[string]any{"name": "status"}))
|
||||||
func OK[T any](data T) Response[T] {
|
func OK[T any](data T) Response[T] {
|
||||||
return Response[T]{
|
return Response[T]{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|
@ -35,6 +54,10 @@ func OK[T any](data T) Response[T] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail creates an error response with the given code and message.
|
// Fail creates an error response with the given code and message.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Name is required"))
|
||||||
func Fail(code, message string) Response[any] {
|
func Fail(code, message string) Response[any] {
|
||||||
return Response[any]{
|
return Response[any]{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|
@ -46,6 +69,10 @@ func Fail(code, message string) Response[any] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FailWithDetails creates an error response with additional detail payload.
|
// FailWithDetails creates an error response with additional detail payload.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// c.JSON(http.StatusBadRequest, api.FailWithDetails("invalid_input", "Name is required", map[string]any{"field": "name"}))
|
||||||
func FailWithDetails(code, message string, details any) Response[any] {
|
func FailWithDetails(code, message string, details any) Response[any] {
|
||||||
return Response[any]{
|
return Response[any]{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|
@ -58,6 +85,10 @@ func FailWithDetails(code, message string, details any) Response[any] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginated wraps data in a successful response with pagination metadata.
|
// Paginated wraps data in a successful response with pagination metadata.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// c.JSON(http.StatusOK, api.Paginated(items, 2, 50, 200))
|
||||||
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
func Paginated[T any](data T, page, perPage, total int) Response[T] {
|
||||||
return Response[T]{
|
return Response[T]{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|
@ -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"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── OK ──────────────────────────────────────────────────────────────────
|
// ── 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"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WithSecure ──────────────────────────────────────────────────────────
|
// ── 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-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WithSlog ──────────────────────────────────────────────────────────
|
// ── 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
|
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.
|
* Return a no workspace response.
|
||||||
*/
|
*/
|
||||||
protected function noWorkspaceResponse(): JsonResponse
|
protected function noWorkspaceResponse(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'no_workspace',
|
errorCode: 'no_workspace',
|
||||||
'message' => 'No workspace found. Please select a workspace first.',
|
message: 'No workspace found. Please select a workspace first.',
|
||||||
], 404);
|
status: 404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,10 +45,14 @@ trait HasApiResponses
|
||||||
*/
|
*/
|
||||||
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
|
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'not_found',
|
errorCode: 'not_found',
|
||||||
'message' => "{$resource} not found.",
|
message: "{$resource} not found.",
|
||||||
], 404);
|
meta: [
|
||||||
|
'resource' => $resource,
|
||||||
|
],
|
||||||
|
status: 404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,12 +60,15 @@ trait HasApiResponses
|
||||||
*/
|
*/
|
||||||
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
|
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'feature_limit_reached',
|
errorCode: 'feature_limit_reached',
|
||||||
'message' => $message ?? 'You have reached your limit for this feature.',
|
message: $message ?? 'You have reached your limit for this feature.',
|
||||||
|
meta: [
|
||||||
'feature' => $feature,
|
'feature' => $feature,
|
||||||
'upgrade_url' => route('hub.usage'),
|
'upgrade_url' => route('hub.usage'),
|
||||||
], 403);
|
],
|
||||||
|
status: 403,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -51,10 +76,20 @@ trait HasApiResponses
|
||||||
*/
|
*/
|
||||||
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
|
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->forbiddenResponse($message, status: 403);
|
||||||
'error' => 'access_denied',
|
}
|
||||||
'message' => $message,
|
|
||||||
], 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
|
protected function successResponse(string $message, array $data = []): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(array_merge([
|
return response()->json(array_merge([
|
||||||
|
'success' => true,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
], $data));
|
], $data));
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +109,7 @@ trait HasApiResponses
|
||||||
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
|
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'data' => $resource,
|
'data' => $resource,
|
||||||
], 201);
|
], 201);
|
||||||
|
|
@ -81,13 +118,16 @@ trait HasApiResponses
|
||||||
/**
|
/**
|
||||||
* Return a validation error response.
|
* Return a validation error response.
|
||||||
*/
|
*/
|
||||||
protected function validationErrorResponse(array $errors): JsonResponse
|
protected function validationErrorResponse(array $errors, int $status = 422): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'validation_failed',
|
errorCode: 'validation_failed',
|
||||||
'message' => 'The given data was invalid.',
|
message: 'The given data was invalid.',
|
||||||
|
meta: [
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
], 422);
|
],
|
||||||
|
status: $status,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,10 +137,11 @@ trait HasApiResponses
|
||||||
*/
|
*/
|
||||||
protected function invalidStatusResponse(string $message): JsonResponse
|
protected function invalidStatusResponse(string $message): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'invalid_status',
|
errorCode: 'invalid_status',
|
||||||
'message' => $message,
|
message: $message,
|
||||||
], 422);
|
status: 422,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,15 +151,13 @@ trait HasApiResponses
|
||||||
*/
|
*/
|
||||||
protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse
|
protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse
|
||||||
{
|
{
|
||||||
$response = [
|
return $this->errorResponse(
|
||||||
'error' => 'provider_error',
|
errorCode: 'provider_error',
|
||||||
'message' => $message,
|
message: $message,
|
||||||
];
|
meta: array_filter([
|
||||||
|
'provider' => $provider,
|
||||||
if ($provider !== null) {
|
]),
|
||||||
$response['provider'] = $provider;
|
status: 400,
|
||||||
}
|
);
|
||||||
|
|
||||||
return response()->json($response, 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;
|
namespace Core\Api\Controllers\Api;
|
||||||
|
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
|
|
@ -16,6 +17,8 @@ use Core\Social\Models\Webhook;
|
||||||
*/
|
*/
|
||||||
class WebhookSecretController extends Controller
|
class WebhookSecretController extends Controller
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected WebhookSecretRotationService $rotationService
|
protected WebhookSecretRotationService $rotationService
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -28,7 +31,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -36,7 +39,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $webhook) {
|
if (! $webhook) {
|
||||||
return response()->json(['error' => 'Webhook not found'], 404);
|
return $this->notFoundResponse('Webhook');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -66,7 +69,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -74,7 +77,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $endpoint) {
|
if (! $endpoint) {
|
||||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
return $this->notFoundResponse('Webhook endpoint');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -104,7 +107,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -112,7 +115,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $webhook) {
|
if (! $webhook) {
|
||||||
return response()->json(['error' => 'Webhook not found'], 404);
|
return $this->notFoundResponse('Webhook');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -128,7 +131,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -136,7 +139,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $endpoint) {
|
if (! $endpoint) {
|
||||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
return $this->notFoundResponse('Webhook endpoint');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -152,7 +155,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -160,7 +163,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $webhook) {
|
if (! $webhook) {
|
||||||
return response()->json(['error' => 'Webhook not found'], 404);
|
return $this->notFoundResponse('Webhook');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->rotationService->invalidatePreviousSecret($webhook);
|
$this->rotationService->invalidatePreviousSecret($webhook);
|
||||||
|
|
@ -179,7 +182,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -187,7 +190,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $endpoint) {
|
if (! $endpoint) {
|
||||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
return $this->notFoundResponse('Webhook endpoint');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->rotationService->invalidatePreviousSecret($endpoint);
|
$this->rotationService->invalidatePreviousSecret($endpoint);
|
||||||
|
|
@ -206,7 +209,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -214,7 +217,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $webhook) {
|
if (! $webhook) {
|
||||||
return response()->json(['error' => 'Webhook not found'], 404);
|
return $this->notFoundResponse('Webhook');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -240,7 +243,7 @@ class WebhookSecretController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -248,7 +251,7 @@ class WebhookSecretController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $endpoint) {
|
if (! $endpoint) {
|
||||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
return $this->notFoundResponse('Webhook endpoint');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Api\Controllers\Api;
|
namespace Core\Api\Controllers\Api;
|
||||||
|
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
|
|
@ -17,6 +18,8 @@ use Core\Api\Services\WebhookTemplateService;
|
||||||
*/
|
*/
|
||||||
class WebhookTemplateController extends Controller
|
class WebhookTemplateController extends Controller
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected WebhookTemplateService $templateService
|
protected WebhookTemplateService $templateService
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -29,7 +32,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -61,7 +64,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -69,7 +72,7 @@ class WebhookTemplateController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
return response()->json(['error' => 'Template not found'], 404);
|
return $this->notFoundResponse('Template');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -85,7 +88,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -102,10 +105,9 @@ class WebhookTemplateController extends Controller
|
||||||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||||
|
|
||||||
if (! $validation['valid']) {
|
if (! $validation['valid']) {
|
||||||
return response()->json([
|
return $this->validationErrorResponse([
|
||||||
'error' => 'Invalid template',
|
'template' => $validation['errors'],
|
||||||
'errors' => $validation['errors'],
|
]);
|
||||||
], 422);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::create([
|
$template = WebhookPayloadTemplate::create([
|
||||||
|
|
@ -133,7 +135,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -141,7 +143,7 @@ class WebhookTemplateController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
return response()->json(['error' => 'Template not found'], 404);
|
return $this->notFoundResponse('Template');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -159,10 +161,9 @@ class WebhookTemplateController extends Controller
|
||||||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||||
|
|
||||||
if (! $validation['valid']) {
|
if (! $validation['valid']) {
|
||||||
return response()->json([
|
return $this->validationErrorResponse([
|
||||||
'error' => 'Invalid template',
|
'template' => $validation['errors'],
|
||||||
'errors' => $validation['errors'],
|
]);
|
||||||
], 422);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +187,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -194,12 +195,12 @@ class WebhookTemplateController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
return response()->json(['error' => 'Template not found'], 404);
|
return $this->notFoundResponse('Template');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow deleting builtin templates
|
// Don't allow deleting builtin templates
|
||||||
if ($template->isBuiltin()) {
|
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();
|
$template->delete();
|
||||||
|
|
@ -255,7 +256,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -263,7 +264,7 @@ class WebhookTemplateController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
return response()->json(['error' => 'Template not found'], 404);
|
return $this->notFoundResponse('Template');
|
||||||
}
|
}
|
||||||
|
|
||||||
$newName = $request->input('name', $template->name.' (copy)');
|
$newName = $request->input('name', $template->name.' (copy)');
|
||||||
|
|
@ -282,7 +283,7 @@ class WebhookTemplateController extends Controller
|
||||||
$workspace = $request->user()?->defaultHostWorkspace();
|
$workspace = $request->user()?->defaultHostWorkspace();
|
||||||
|
|
||||||
if (! $workspace) {
|
if (! $workspace) {
|
||||||
return response()->json(['error' => 'Workspace not found'], 404);
|
return $this->noWorkspaceResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -290,7 +291,7 @@ class WebhookTemplateController extends Controller
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
return response()->json(['error' => 'Template not found'], 404);
|
return $this->notFoundResponse('Template');
|
||||||
}
|
}
|
||||||
|
|
||||||
$template->setAsDefault();
|
$template->setAsDefault();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||||
namespace Core\Api\Controllers;
|
namespace Core\Api\Controllers;
|
||||||
|
|
||||||
use Core\Front\Controller;
|
use Core\Front\Controller;
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
|
use Core\Api\Documentation\Attributes\ApiParameter;
|
||||||
use Core\Api\Models\ApiKey;
|
use Core\Api\Models\ApiKey;
|
||||||
use Core\Mod\Mcp\Models\McpApiRequest;
|
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||||
use Core\Mod\Mcp\Models\McpToolCall;
|
use Core\Mod\Mcp\Models\McpToolCall;
|
||||||
|
|
@ -23,6 +25,8 @@ use Symfony\Component\Yaml\Yaml;
|
||||||
*/
|
*/
|
||||||
class McpApiController extends Controller
|
class McpApiController extends Controller
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all available MCP servers.
|
* List all available MCP servers.
|
||||||
*
|
*
|
||||||
|
|
@ -47,15 +51,48 @@ class McpApiController extends Controller
|
||||||
* Get server details with tools and resources.
|
* Get server details with tools and resources.
|
||||||
*
|
*
|
||||||
* GET /api/v1/mcp/servers/{id}
|
* GET /api/v1/mcp/servers/{id}
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - include_versions: bool - include version info for each tool
|
||||||
|
* - include_content: bool - include resource content when the definition already contains it
|
||||||
*/
|
*/
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'include_versions',
|
||||||
|
in: 'query',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include version information for each tool',
|
||||||
|
required: false,
|
||||||
|
example: false,
|
||||||
|
default: false
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'include_content',
|
||||||
|
in: 'query',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include resource content when the definition already contains it',
|
||||||
|
required: false,
|
||||||
|
example: false,
|
||||||
|
default: false
|
||||||
|
)]
|
||||||
public function server(Request $request, string $id): JsonResponse
|
public function server(Request $request, string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$server = $this->loadServerFull($id);
|
$server = $this->loadServerFull($id);
|
||||||
|
|
||||||
if (! $server) {
|
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);
|
return response()->json($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,12 +104,21 @@ class McpApiController extends Controller
|
||||||
* Query params:
|
* Query params:
|
||||||
* - include_versions: bool - include version info for each tool
|
* - include_versions: bool - include version info for each tool
|
||||||
*/
|
*/
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'include_versions',
|
||||||
|
in: 'query',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include version information for each tool',
|
||||||
|
required: false,
|
||||||
|
example: false,
|
||||||
|
default: false
|
||||||
|
)]
|
||||||
public function tools(Request $request, string $id): JsonResponse
|
public function tools(Request $request, string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$server = $this->loadServerFull($id);
|
$server = $this->loadServerFull($id);
|
||||||
|
|
||||||
if (! $server) {
|
if (! $server) {
|
||||||
return response()->json(['error' => 'Server not found'], 404);
|
return $this->notFoundResponse('Server');
|
||||||
}
|
}
|
||||||
|
|
||||||
$tools = $server['tools'] ?? [];
|
$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.
|
* Execute a tool on an MCP server.
|
||||||
*
|
*
|
||||||
|
|
@ -129,13 +285,13 @@ class McpApiController extends Controller
|
||||||
|
|
||||||
$server = $this->loadServerFull($validated['server']);
|
$server = $this->loadServerFull($validated['server']);
|
||||||
if (! $server) {
|
if (! $server) {
|
||||||
return response()->json(['error' => 'Server not found'], 404);
|
return $this->notFoundResponse('Server');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tool exists in server definition
|
// Verify tool exists in server definition
|
||||||
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
||||||
if (! $toolDef) {
|
if (! $toolDef) {
|
||||||
return response()->json(['error' => 'Tool not found'], 404);
|
return $this->notFoundResponse('Tool');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version resolution
|
// Version resolution
|
||||||
|
|
@ -153,16 +309,18 @@ class McpApiController extends Controller
|
||||||
// Sunset versions return 410 Gone
|
// Sunset versions return 410 Gone
|
||||||
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
|
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
|
||||||
|
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'success' => false,
|
errorCode: $error['code'] ?? 'VERSION_ERROR',
|
||||||
'error' => $error['message'] ?? 'Version error',
|
message: $error['message'] ?? 'Version error',
|
||||||
'error_code' => $error['code'] ?? 'VERSION_ERROR',
|
meta: [
|
||||||
'server' => $validated['server'],
|
'server' => $validated['server'],
|
||||||
'tool' => $validated['tool'],
|
'tool' => $validated['tool'],
|
||||||
'requested_version' => $validated['version'] ?? null,
|
'requested_version' => $validated['version'] ?? null,
|
||||||
'latest_version' => $error['latest_version'] ?? null,
|
'latest_version' => $error['latest_version'] ?? null,
|
||||||
'migration_notes' => $error['migration_notes'] ?? null,
|
'migration_notes' => $error['migration_notes'] ?? null,
|
||||||
], $status);
|
],
|
||||||
|
status: $status,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var McpToolVersion|null $toolVersion */
|
/** @var McpToolVersion|null $toolVersion */
|
||||||
|
|
@ -178,15 +336,17 @@ class McpApiController extends Controller
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! empty($validationErrors)) {
|
if (! empty($validationErrors)) {
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'success' => false,
|
errorCode: 'VALIDATION_ERROR',
|
||||||
'error' => 'Validation failed',
|
message: 'Validation failed',
|
||||||
'error_code' => 'VALIDATION_ERROR',
|
meta: [
|
||||||
'validation_errors' => $validationErrors,
|
'validation_errors' => $validationErrors,
|
||||||
'server' => $validated['server'],
|
'server' => $validated['server'],
|
||||||
'tool' => $validated['tool'],
|
'tool' => $validated['tool'],
|
||||||
'version' => $toolVersion?->version ?? 'unversioned',
|
'version' => $toolVersion?->version ?? 'unversioned',
|
||||||
], 422);
|
],
|
||||||
|
status: 422,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +361,8 @@ class McpApiController extends Controller
|
||||||
$result = $this->executeToolViaArtisan(
|
$result = $this->executeToolViaArtisan(
|
||||||
$validated['server'],
|
$validated['server'],
|
||||||
$validated['tool'],
|
$validated['tool'],
|
||||||
$validated['arguments'] ?? []
|
$validated['arguments'] ?? [],
|
||||||
|
$toolVersion?->version
|
||||||
);
|
);
|
||||||
|
|
||||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
@ -262,7 +423,16 @@ class McpApiController extends Controller
|
||||||
// Log full request for debugging/replay
|
// Log full request for debugging/replay
|
||||||
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
|
$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);
|
$serverConfig = $this->loadServerFull($server);
|
||||||
if (! $serverConfig) {
|
if (! $serverConfig) {
|
||||||
return response()->json(['error' => 'Server not found'], 404);
|
return $this->notFoundResponse('Server');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tool exists in server definition
|
// Verify tool exists in server definition
|
||||||
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
|
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
|
||||||
if (! $toolDef) {
|
if (! $toolDef) {
|
||||||
return response()->json(['error' => 'Tool not found'], 404);
|
return $this->notFoundResponse('Tool');
|
||||||
}
|
}
|
||||||
|
|
||||||
$versionService = app(ToolVersionService::class);
|
$versionService = app(ToolVersionService::class);
|
||||||
|
|
@ -374,7 +544,7 @@ class McpApiController extends Controller
|
||||||
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
|
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
|
||||||
|
|
||||||
if (! $toolVersion) {
|
if (! $toolVersion) {
|
||||||
return response()->json(['error' => 'Version not found'], 404);
|
return $this->notFoundResponse('Version');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = response()->json($toolVersion->toApiArray());
|
$response = response()->json($toolVersion->toApiArray());
|
||||||
|
|
@ -397,9 +567,13 @@ class McpApiController extends Controller
|
||||||
*/
|
*/
|
||||||
public function resource(Request $request, string $uri): JsonResponse
|
public function resource(Request $request, string $uri): JsonResponse
|
||||||
{
|
{
|
||||||
|
$uri = rawurldecode($uri);
|
||||||
|
|
||||||
// Parse URI format: server://resource/path
|
// Parse URI format: server://resource/path
|
||||||
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||||
return 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];
|
$serverId = $matches[1];
|
||||||
|
|
@ -407,53 +581,62 @@ class McpApiController extends Controller
|
||||||
|
|
||||||
$server = $this->loadServerFull($serverId);
|
$server = $this->loadServerFull($serverId);
|
||||||
if (! $server) {
|
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 {
|
try {
|
||||||
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
||||||
|
if ($result === null) {
|
||||||
|
return $this->notFoundResponse('Resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($result) && array_key_exists('content', $result)) {
|
||||||
|
$content = $result['content'];
|
||||||
|
} elseif (is_array($result) && array_key_exists('contents', $result)) {
|
||||||
|
$content = $result['contents'];
|
||||||
|
} else {
|
||||||
|
$content = $result;
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'uri' => $uri,
|
'uri' => $uri,
|
||||||
'content' => $result,
|
'server' => $serverId,
|
||||||
|
'resource' => $resourcePath,
|
||||||
|
'content' => $content,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => $e->getMessage(),
|
errorCode: 'resource_read_error',
|
||||||
|
message: $e->getMessage(),
|
||||||
|
meta: [
|
||||||
'uri' => $uri,
|
'uri' => $uri,
|
||||||
], 500);
|
],
|
||||||
|
status: 500,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute tool via artisan MCP server command.
|
* 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 = [
|
$command = $this->resolveMcpServerCommand($server);
|
||||||
'hosthub-agent' => 'mcp:agent-server',
|
|
||||||
'socialhost' => 'mcp:socialhost-server',
|
|
||||||
'biohost' => 'mcp:biohost-server',
|
|
||||||
'commerce' => 'mcp:commerce-server',
|
|
||||||
'supporthost' => 'mcp:support-server',
|
|
||||||
'upstream' => 'mcp:upstream-server',
|
|
||||||
];
|
|
||||||
|
|
||||||
$command = $commandMap[$server] ?? null;
|
|
||||||
if (! $command) {
|
if (! $command) {
|
||||||
throw new \RuntimeException("Unknown server: {$server}");
|
throw new \RuntimeException("Unknown server: {$server}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build MCP request
|
$mcpRequest = $this->buildToolCallRequest($tool, $arguments, $version);
|
||||||
$mcpRequest = [
|
|
||||||
'jsonrpc' => '2.0',
|
|
||||||
'id' => uniqid(),
|
|
||||||
'method' => 'tools/call',
|
|
||||||
'params' => [
|
|
||||||
'name' => $tool,
|
|
||||||
'arguments' => $arguments,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Execute via process
|
// Execute via process
|
||||||
$process = proc_open(
|
$process = proc_open(
|
||||||
|
|
@ -489,14 +672,157 @@ class McpApiController extends Controller
|
||||||
return $response['result'] ?? null;
|
return $response['result'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the JSON-RPC payload for an MCP tool call.
|
||||||
|
*/
|
||||||
|
protected function buildToolCallRequest(string $tool, array $arguments, ?string $version = null): array
|
||||||
|
{
|
||||||
|
$params = [
|
||||||
|
'name' => $tool,
|
||||||
|
'arguments' => $arguments,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($version !== null && $version !== '') {
|
||||||
|
$params['version'] = $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => uniqid(),
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => $params,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read resource via artisan MCP server command.
|
* Read resource via artisan MCP server command.
|
||||||
*/
|
*/
|
||||||
protected function readResourceViaArtisan(string $server, string $path): mixed
|
protected function readResourceViaArtisan(string $server, string $path): mixed
|
||||||
{
|
{
|
||||||
// Similar to executeToolViaArtisan but with resources/read method
|
$command = $this->resolveMcpServerCommand($server);
|
||||||
// Simplified for now - can expand later
|
if (! $command) {
|
||||||
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
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());
|
* return UserResource::collection(User::paginate());
|
||||||
* }
|
* }
|
||||||
|
*
|
||||||
|
* // For non-JSON or binary responses
|
||||||
|
* #[ApiResponse(
|
||||||
|
* 200,
|
||||||
|
* null,
|
||||||
|
* 'Transparent tracking pixel',
|
||||||
|
* contentType: 'image/gif',
|
||||||
|
* schema: ['type' => 'string', 'format' => 'binary']
|
||||||
|
* )]
|
||||||
|
* public function pixel()
|
||||||
|
* {
|
||||||
|
* return response($gif, 200)->header('Content-Type', 'image/gif');
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||||
readonly class ApiResponse
|
readonly class ApiResponse
|
||||||
|
|
@ -37,6 +50,8 @@ readonly class ApiResponse
|
||||||
* @param string|null $description Description of the response
|
* @param string|null $description Description of the response
|
||||||
* @param bool $paginated Whether this is a paginated collection response
|
* @param bool $paginated Whether this is a paginated collection response
|
||||||
* @param array<string> $headers Additional response headers to document
|
* @param array<string> $headers Additional response headers to document
|
||||||
|
* @param string|null $contentType Explicit response media type for non-JSON responses
|
||||||
|
* @param array<string, mixed>|null $schema Explicit response schema when the body is not inferred from a resource
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $status,
|
public int $status,
|
||||||
|
|
@ -44,6 +59,8 @@ readonly class ApiResponse
|
||||||
public ?string $description = null,
|
public ?string $description = null,
|
||||||
public bool $paginated = false,
|
public bool $paginated = false,
|
||||||
public array $headers = [],
|
public array $headers = [],
|
||||||
|
public ?string $contentType = null,
|
||||||
|
public ?array $schema = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -64,10 +81,11 @@ readonly class ApiResponse
|
||||||
302 => 'Found (redirect)',
|
302 => 'Found (redirect)',
|
||||||
304 => 'Not modified',
|
304 => 'Not modified',
|
||||||
400 => 'Bad request',
|
400 => 'Bad request',
|
||||||
401 => 'Unauthorized',
|
401 => 'Unauthorised',
|
||||||
403 => 'Forbidden',
|
403 => 'Forbidden',
|
||||||
404 => 'Not found',
|
404 => 'Not found',
|
||||||
405 => 'Method not allowed',
|
405 => 'Method not allowed',
|
||||||
|
410 => 'Gone',
|
||||||
409 => 'Conflict',
|
409 => 'Conflict',
|
||||||
422 => 'Validation error',
|
422 => 'Validation error',
|
||||||
429 => 'Too many requests',
|
429 => 'Too many requests',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class DocumentationController
|
||||||
return match ($defaultUi) {
|
return match ($defaultUi) {
|
||||||
'swagger' => $this->swagger($request),
|
'swagger' => $this->swagger($request),
|
||||||
'redoc' => $this->redoc($request),
|
'redoc' => $this->redoc($request),
|
||||||
|
'stoplight' => $this->stoplight($request),
|
||||||
default => $this->scalar($request),
|
default => $this->scalar($request),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
* Get OpenAPI specification as JSON.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class ApiKeyAuthExtension implements Extension
|
||||||
'properties' => [
|
'properties' => [
|
||||||
'message' => [
|
'message' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'example' => 'This action is 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\Attributes\ApiTag;
|
||||||
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
||||||
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
use Core\Api\Documentation\Extensions\RateLimitExtension;
|
||||||
|
use Core\Api\Documentation\Extensions\SunsetExtension;
|
||||||
|
use Core\Api\Documentation\Extensions\VersionExtension;
|
||||||
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Routing\Route;
|
use Illuminate\Routing\Route;
|
||||||
|
|
@ -57,7 +59,9 @@ class OpenApiBuilder
|
||||||
{
|
{
|
||||||
$this->extensions = [
|
$this->extensions = [
|
||||||
new WorkspaceHeaderExtension,
|
new WorkspaceHeaderExtension,
|
||||||
|
new VersionExtension,
|
||||||
new RateLimitExtension,
|
new RateLimitExtension,
|
||||||
|
new SunsetExtension,
|
||||||
new ApiKeyAuthExtension,
|
new ApiKeyAuthExtension,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +233,7 @@ class OpenApiBuilder
|
||||||
protected function buildPaths(array $config): array
|
protected function buildPaths(array $config): array
|
||||||
{
|
{
|
||||||
$paths = [];
|
$paths = [];
|
||||||
|
$operationIds = [];
|
||||||
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
||||||
$excludePatterns = $config['routes']['exclude'] ?? [];
|
$excludePatterns = $config['routes']['exclude'] ?? [];
|
||||||
|
|
||||||
|
|
@ -243,7 +248,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
foreach ($methods as $method) {
|
foreach ($methods as $method) {
|
||||||
$method = strtolower($method);
|
$method = strtolower($method);
|
||||||
$operation = $this->buildOperation($route, $method, $config);
|
$operation = $this->buildOperation($route, $method, $config, $operationIds);
|
||||||
|
|
||||||
if ($operation !== null) {
|
if ($operation !== null) {
|
||||||
$paths[$path][$method] = $operation;
|
$paths[$path][$method] = $operation;
|
||||||
|
|
@ -297,7 +302,7 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build operation for a specific route and method.
|
* Build operation for a specific route and method.
|
||||||
*/
|
*/
|
||||||
protected function buildOperation(Route $route, string $method, array $config): ?array
|
protected function buildOperation(Route $route, string $method, array $config, array &$operationIds): ?array
|
||||||
{
|
{
|
||||||
$controller = $route->getController();
|
$controller = $route->getController();
|
||||||
$action = $route->getActionMethod();
|
$action = $route->getActionMethod();
|
||||||
|
|
@ -309,7 +314,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
$operation = [
|
$operation = [
|
||||||
'summary' => $this->buildSummary($route, $method),
|
'summary' => $this->buildSummary($route, $method),
|
||||||
'operationId' => $this->buildOperationId($route, $method),
|
'operationId' => $this->buildOperationId($route, $method, $operationIds),
|
||||||
'tags' => $this->buildOperationTags($route, $controller, $action),
|
'tags' => $this->buildOperationTags($route, $controller, $action),
|
||||||
'responses' => $this->buildResponses($controller, $action),
|
'responses' => $this->buildResponses($controller, $action),
|
||||||
];
|
];
|
||||||
|
|
@ -328,7 +333,7 @@ class OpenApiBuilder
|
||||||
|
|
||||||
// Add request body for POST/PUT/PATCH
|
// Add request body for POST/PUT/PATCH
|
||||||
if (in_array($method, ['post', 'put', 'patch'])) {
|
if (in_array($method, ['post', 'put', 'patch'])) {
|
||||||
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
|
$operation['requestBody'] = $this->buildRequestBody($route, $controller, $action);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add security requirements
|
// Add security requirements
|
||||||
|
|
@ -398,15 +403,24 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build operation ID from route name.
|
* 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();
|
$name = $route->getName();
|
||||||
|
|
||||||
if ($name) {
|
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
|
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
|
||||||
{
|
{
|
||||||
$parameters = [];
|
$parameters = [];
|
||||||
|
$parameterIndex = [];
|
||||||
|
|
||||||
|
$addParameter = function (array $parameter) use (&$parameters, &$parameterIndex): void {
|
||||||
|
$name = $parameter['name'] ?? null;
|
||||||
|
$in = $parameter['in'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($name) || $name === '' || ! is_string($in) || $in === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $in.':'.$name;
|
||||||
|
if (isset($parameterIndex[$key])) {
|
||||||
|
$parameters[$parameterIndex[$key]] = $parameter;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameterIndex[$key] = count($parameters);
|
||||||
|
$parameters[] = $parameter;
|
||||||
|
};
|
||||||
|
|
||||||
// Add path parameters
|
// Add path parameters
|
||||||
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
||||||
foreach ($matches[1] as $param) {
|
foreach ($matches[1] as $param) {
|
||||||
$parameters[] = [
|
$addParameter([
|
||||||
'name' => $param,
|
'name' => $param,
|
||||||
'in' => 'path',
|
'in' => 'path',
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'schema' => ['type' => 'string'],
|
'schema' => ['type' => 'string'],
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add parameters from ApiParameter attributes
|
// Add parameters from ApiParameter attributes
|
||||||
|
|
@ -532,12 +566,12 @@ class OpenApiBuilder
|
||||||
|
|
||||||
foreach ($paramAttrs as $attr) {
|
foreach ($paramAttrs as $attr) {
|
||||||
$param = $attr->newInstance();
|
$param = $attr->newInstance();
|
||||||
$parameters[] = $param->toOpenApi();
|
$addParameter($param->toOpenApi());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $parameters;
|
return array_values($parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -578,15 +612,23 @@ class OpenApiBuilder
|
||||||
'description' => $response->getDescription(),
|
'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);
|
$schema = $this->extractResourceSchema($response->resource);
|
||||||
|
|
||||||
if ($response->paginated) {
|
if ($response->paginated) {
|
||||||
$schema = $this->wrapPaginatedSchema($schema);
|
$schema = $this->wrapPaginatedSchema($schema);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schema !== null) {
|
||||||
|
$contentType = $response->contentType ?: 'application/json';
|
||||||
|
|
||||||
$result['content'] = [
|
$result['content'] = [
|
||||||
'application/json' => [
|
$contentType => [
|
||||||
'schema' => $schema,
|
'schema' => $schema,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -614,14 +656,181 @@ class OpenApiBuilder
|
||||||
return ['type' => 'object'];
|
return ['type' => 'object'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now, return a generic object schema
|
try {
|
||||||
// A more sophisticated implementation would analyze the resource's toArray method
|
$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 [
|
return [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'additionalProperties' => true,
|
'additionalProperties' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer an OpenAPI schema from a PHP array.
|
||||||
|
*/
|
||||||
|
protected function inferArraySchema(array $value): array
|
||||||
|
{
|
||||||
|
if (array_is_list($value)) {
|
||||||
|
$itemSchema = ['type' => 'object'];
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if ($item === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemSchema = $this->inferValueSchema($item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => $itemSchema,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$properties = [];
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
$properties[(string) $key] = $this->inferValueSchema($item, (string) $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => $properties,
|
||||||
|
'additionalProperties' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer an OpenAPI schema node from a PHP value.
|
||||||
|
*/
|
||||||
|
protected function inferValueSchema(mixed $value, ?string $key = null): array
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return $this->inferNullableSchema($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return ['type' => 'boolean'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
return ['type' => 'integer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_float($value)) {
|
||||||
|
return ['type' => 'number'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $this->inferStringSchema($value, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $this->inferArraySchema($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($value)) {
|
||||||
|
return $this->inferObjectSchema($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a schema for a null value using the field name as a hint.
|
||||||
|
*/
|
||||||
|
protected function inferNullableSchema(?string $key): array
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return ['nullable' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($key);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$normalized === 'id',
|
||||||
|
str_ends_with($normalized, '_id'),
|
||||||
|
str_ends_with($normalized, 'count'),
|
||||||
|
str_ends_with($normalized, 'total'),
|
||||||
|
str_ends_with($normalized, 'page'),
|
||||||
|
str_ends_with($normalized, 'limit'),
|
||||||
|
str_ends_with($normalized, 'offset'),
|
||||||
|
str_ends_with($normalized, 'size'),
|
||||||
|
str_ends_with($normalized, 'quantity'),
|
||||||
|
str_ends_with($normalized, 'rank'),
|
||||||
|
str_ends_with($normalized, 'score') => ['type' => 'integer', 'nullable' => true],
|
||||||
|
str_starts_with($normalized, 'is_'),
|
||||||
|
str_starts_with($normalized, 'has_'),
|
||||||
|
str_starts_with($normalized, 'can_'),
|
||||||
|
str_starts_with($normalized, 'should_'),
|
||||||
|
str_starts_with($normalized, 'enabled'),
|
||||||
|
str_starts_with($normalized, 'active') => ['type' => 'boolean', 'nullable' => true],
|
||||||
|
str_ends_with($normalized, '_at'),
|
||||||
|
str_ends_with($normalized, '_on'),
|
||||||
|
str_contains($normalized, 'date'),
|
||||||
|
str_contains($normalized, 'time'),
|
||||||
|
str_contains($normalized, 'timestamp') => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
|
||||||
|
str_contains($normalized, 'email') => ['type' => 'string', 'format' => 'email', 'nullable' => true],
|
||||||
|
str_contains($normalized, 'url'),
|
||||||
|
str_contains($normalized, 'uri') => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
|
||||||
|
str_contains($normalized, 'uuid') => ['type' => 'string', 'format' => 'uuid', 'nullable' => true],
|
||||||
|
str_contains($normalized, 'name'),
|
||||||
|
str_contains($normalized, 'title'),
|
||||||
|
str_contains($normalized, 'description'),
|
||||||
|
str_contains($normalized, 'status'),
|
||||||
|
str_contains($normalized, 'type'),
|
||||||
|
str_contains($normalized, 'code'),
|
||||||
|
str_contains($normalized, 'token'),
|
||||||
|
str_contains($normalized, 'slug'),
|
||||||
|
str_contains($normalized, 'key') => ['type' => 'string', 'nullable' => true],
|
||||||
|
default => ['nullable' => true],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a schema for a string value using the field name as a hint.
|
||||||
|
*/
|
||||||
|
protected function inferStringSchema(string $value, ?string $key): array
|
||||||
|
{
|
||||||
|
if ($key !== null) {
|
||||||
|
$nullable = $this->inferNullableSchema($key);
|
||||||
|
|
||||||
|
if (($nullable['type'] ?? null) === 'string') {
|
||||||
|
$nullable['nullable'] = false;
|
||||||
|
return $nullable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'string'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer a schema for an object value.
|
||||||
|
*/
|
||||||
|
protected function inferObjectSchema(object $value): array
|
||||||
|
{
|
||||||
|
$properties = [];
|
||||||
|
|
||||||
|
foreach (get_object_vars($value) as $key => $item) {
|
||||||
|
$properties[$key] = $this->inferValueSchema($item, (string) $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => $properties,
|
||||||
|
'additionalProperties' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap schema in pagination structure.
|
* Wrap schema in pagination structure.
|
||||||
*/
|
*/
|
||||||
|
|
@ -661,8 +870,45 @@ class OpenApiBuilder
|
||||||
/**
|
/**
|
||||||
* Build request body schema.
|
* 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 [
|
return [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'content' => [
|
'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('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
|
||||||
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
||||||
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
||||||
|
Route::get('/stoplight', [DocumentationController::class, 'stoplight'])->name('api.docs.stoplight');
|
||||||
|
|
||||||
// OpenAPI specification routes
|
// OpenAPI specification routes
|
||||||
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
|
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
|
||||||
|
|
|
||||||
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_download_button' => false,
|
||||||
'hide_models' => false,
|
'hide_models' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Stoplight Elements specific options
|
||||||
|
'stoplight' => [
|
||||||
|
'theme' => 'dark', // 'dark' or 'light'
|
||||||
|
'layout' => 'sidebar', // 'sidebar' or 'stacked'
|
||||||
|
'hide_try_it' => false,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||||
namespace Core\Api\Exceptions;
|
namespace Core\Api\Exceptions;
|
||||||
|
|
||||||
use Core\Api\RateLimit\RateLimitResult;
|
use Core\Api\RateLimit\RateLimitResult;
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,6 +17,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
*/
|
*/
|
||||||
class RateLimitExceededException extends HttpException
|
class RateLimitExceededException extends HttpException
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected RateLimitResult $rateLimitResult,
|
protected RateLimitResult $rateLimitResult,
|
||||||
string $message = 'Too many requests. Please slow down.',
|
string $message = 'Too many requests. Please slow down.',
|
||||||
|
|
@ -33,15 +37,26 @@ class RateLimitExceededException extends HttpException
|
||||||
/**
|
/**
|
||||||
* Render the exception as a JSON response.
|
* Render the exception as a JSON response.
|
||||||
*/
|
*/
|
||||||
public function render(): JsonResponse
|
public function render(?Request $request = null): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
$response = $this->errorResponse(
|
||||||
'error' => 'rate_limit_exceeded',
|
errorCode: 'rate_limit_exceeded',
|
||||||
'message' => $this->getMessage(),
|
message: $this->getMessage(),
|
||||||
|
meta: [
|
||||||
'retry_after' => $this->rateLimitResult->retryAfter,
|
'retry_after' => $this->rateLimitResult->retryAfter,
|
||||||
'limit' => $this->rateLimitResult->limit,
|
'limit' => $this->rateLimitResult->limit,
|
||||||
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
|
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
|
||||||
], 429, $this->rateLimitResult->headers());
|
],
|
||||||
|
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\Models\ApiKey;
|
||||||
use Core\Api\Services\IpRestrictionService;
|
use Core\Api\Services\IpRestrictionService;
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
@ -24,6 +25,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
*/
|
*/
|
||||||
class AuthenticateApiKey
|
class AuthenticateApiKey
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next, ?string $scope = null): Response
|
public function handle(Request $request, Closure $next, ?string $scope = null): Response
|
||||||
{
|
{
|
||||||
$token = $request->bearerToken();
|
$token = $request->bearerToken();
|
||||||
|
|
@ -113,14 +116,15 @@ class AuthenticateApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return 401 Unauthorized response.
|
* Return 401 Unauthorised response.
|
||||||
*/
|
*/
|
||||||
protected function unauthorized(string $message): Response
|
protected function unauthorized(string $message): Response
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->errorResponse(
|
||||||
'error' => 'unauthorized',
|
errorCode: 'unauthorized',
|
||||||
'message' => $message,
|
message: $message,
|
||||||
], 401);
|
status: 401,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -128,9 +132,6 @@ class AuthenticateApiKey
|
||||||
*/
|
*/
|
||||||
protected function forbidden(string $message): Response
|
protected function forbidden(string $message): Response
|
||||||
{
|
{
|
||||||
return response()->json([
|
return $this->forbiddenResponse($message, status: 403);
|
||||||
'error' => 'forbidden',
|
|
||||||
'message' => $message,
|
|
||||||
], 403);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Core\Api\Middleware;
|
namespace Core\Api\Middleware;
|
||||||
|
|
||||||
use Core\Api\Models\ApiKey;
|
use Core\Api\Models\ApiKey;
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
*/
|
*/
|
||||||
class CheckApiScope
|
class CheckApiScope
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next, string ...$scopes): Response
|
public function handle(Request $request, Closure $next, string ...$scopes): Response
|
||||||
{
|
{
|
||||||
$apiKey = $request->attributes->get('api_key');
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
|
@ -38,12 +41,13 @@ class CheckApiScope
|
||||||
// Check all required scopes
|
// Check all required scopes
|
||||||
foreach ($scopes as $scope) {
|
foreach ($scopes as $scope) {
|
||||||
if (! $apiKey->hasScope($scope)) {
|
if (! $apiKey->hasScope($scope)) {
|
||||||
return response()->json([
|
return $this->forbiddenResponse(
|
||||||
'error' => 'forbidden',
|
message: "API key missing required scope: {$scope}",
|
||||||
'message' => "API key missing required scope: {$scope}",
|
meta: [
|
||||||
'required_scopes' => $scopes,
|
'required_scopes' => $scopes,
|
||||||
'key_scopes' => $apiKey->scopes,
|
'key_scopes' => $apiKey->scopes,
|
||||||
], 403);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Core\Api\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Core\Api\Models\ApiKey;
|
use Core\Api\Models\ApiKey;
|
||||||
|
use Core\Api\Concerns\HasApiResponses;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
*/
|
*/
|
||||||
class EnforceApiScope
|
class EnforceApiScope
|
||||||
{
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP method to required scope mapping.
|
* HTTP method to required scope mapping.
|
||||||
*/
|
*/
|
||||||
|
|
@ -52,12 +55,13 @@ class EnforceApiScope
|
||||||
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
|
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
|
||||||
|
|
||||||
if (! $apiKey->hasScope($requiredScope)) {
|
if (! $apiKey->hasScope($requiredScope)) {
|
||||||
return response()->json([
|
return $this->forbiddenResponse(
|
||||||
'error' => 'forbidden',
|
message: "API key missing required scope: {$requiredScope}",
|
||||||
'message' => "API key missing required scope: {$requiredScope}",
|
meta: [
|
||||||
'detail' => "{$method} requests require '{$requiredScope}' scope",
|
'detail' => "{$method} requests require '{$requiredScope}' scope",
|
||||||
'key_scopes' => $apiKey->scopes,
|
'key_scopes' => $apiKey->scopes,
|
||||||
], 403);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class ErrorResource extends JsonResource
|
||||||
/**
|
/**
|
||||||
* Common error factory methods.
|
* 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);
|
return new static('unauthorized', $message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Api\Controllers\Api\UnifiedPixelController;
|
||||||
|
use Core\Api\Controllers\Api\EntitlementApiController;
|
||||||
|
use Core\Api\Controllers\Api\SeoReportController;
|
||||||
|
use Core\Api\Controllers\Api\WebhookSecretController;
|
||||||
use Core\Api\Controllers\McpApiController;
|
use Core\Api\Controllers\McpApiController;
|
||||||
|
use Core\Api\Middleware\PublicApiCors;
|
||||||
use Core\Mcp\Middleware\McpApiKeyAuth;
|
use Core\Mcp\Middleware\McpApiKeyAuth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
|
@ -13,11 +18,81 @@ use Illuminate\Support\Facades\Route;
|
||||||
|
|
|
|
||||||
| Core API routes for cross-cutting concerns.
|
| Core API routes for cross-cutting concerns.
|
||||||
|
|
|
|
||||||
| TODO: SeoReportController, UnifiedPixelController, EntitlementApiController
|
| SEO, pixel tracking, entitlements, and MCP bridge endpoints.
|
||||||
| are planned but not yet implemented. Re-add routes when controllers exist.
|
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Unified Pixel (public tracking)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware([PublicApiCors::class, 'api.rate'])
|
||||||
|
->prefix('pixel')
|
||||||
|
->name('api.pixel.')
|
||||||
|
->group(function () {
|
||||||
|
Route::match(['GET', 'POST', 'OPTIONS'], '/{pixelKey}', [UnifiedPixelController::class, 'track'])
|
||||||
|
->name('track');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SEO analysis (authenticated)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware(['auth.api', 'api.scope.enforce'])
|
||||||
|
->prefix('seo')
|
||||||
|
->name('api.seo.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/report', [SeoReportController::class, 'show'])
|
||||||
|
->name('report');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Entitlements (authenticated)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware(['auth.api', 'api.scope.enforce'])
|
||||||
|
->prefix('entitlements')
|
||||||
|
->name('api.entitlements.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/', [EntitlementApiController::class, 'show'])
|
||||||
|
->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook secret rotation (authenticated)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware(['auth.api', 'api.scope.enforce'])
|
||||||
|
->prefix('webhooks')
|
||||||
|
->name('api.webhooks.')
|
||||||
|
->group(function () {
|
||||||
|
Route::prefix('social/{uuid}/secret')
|
||||||
|
->name('social.')
|
||||||
|
->group(function () {
|
||||||
|
Route::post('/rotate', [WebhookSecretController::class, 'rotateSocialSecret'])
|
||||||
|
->name('rotate-secret');
|
||||||
|
Route::get('/', [WebhookSecretController::class, 'socialSecretStatus'])
|
||||||
|
->name('status');
|
||||||
|
Route::delete('/previous', [WebhookSecretController::class, 'invalidateSocialPreviousSecret'])
|
||||||
|
->name('invalidate-previous');
|
||||||
|
Route::patch('/grace-period', [WebhookSecretController::class, 'updateSocialGracePeriod'])
|
||||||
|
->name('grace-period');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::prefix('content/{uuid}/secret')
|
||||||
|
->name('content.')
|
||||||
|
->group(function () {
|
||||||
|
Route::post('/rotate', [WebhookSecretController::class, 'rotateContentSecret'])
|
||||||
|
->name('rotate-secret');
|
||||||
|
Route::get('/', [WebhookSecretController::class, 'contentSecretStatus'])
|
||||||
|
->name('status');
|
||||||
|
Route::delete('/previous', [WebhookSecretController::class, 'invalidateContentPreviousSecret'])
|
||||||
|
->name('invalidate-previous');
|
||||||
|
Route::patch('/grace-period', [WebhookSecretController::class, 'updateContentGracePeriod'])
|
||||||
|
->name('grace-period');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// MCP HTTP Bridge (API key auth)
|
// MCP HTTP Bridge (API key auth)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -34,6 +109,8 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
|
||||||
->name('servers.show');
|
->name('servers.show');
|
||||||
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
||||||
->name('servers.tools');
|
->name('servers.tools');
|
||||||
|
Route::get('/servers/{id}/resources', [McpApiController::class, 'resources'])
|
||||||
|
->name('servers.resources');
|
||||||
|
|
||||||
// Tool version history (read)
|
// Tool version history (read)
|
||||||
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Mod\Api\Services;
|
namespace Core\Api\Services;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Mod\Api\Models\ApiUsage;
|
use Core\Api\Models\ApiUsage;
|
||||||
use Mod\Api\Models\ApiUsageDaily;
|
use Core\Api\Models\ApiUsageDaily;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Usage Service - tracks and reports API usage metrics.
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Mod\Api\Models\ApiKey;
|
use Core\Api\Models\ApiKey;
|
||||||
use Mod\Api\Models\ApiUsage;
|
use Core\Api\Models\ApiUsage;
|
||||||
use Mod\Api\Models\ApiUsageDaily;
|
use Core\Api\Models\ApiUsageDaily;
|
||||||
use Mod\Api\Services\ApiUsageService;
|
use Core\Api\Services\ApiUsageService;
|
||||||
use Mod\Tenant\Models\User;
|
use Core\Tenant\Models\User;
|
||||||
use Mod\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
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);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue