feat(api): merge go-api + php-api into polyglot repo

Go source at root level (Option B), PHP under src/php/.
Module path: forge.lthn.ai/core/api
Package name: lthn/api

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-14 10:03:29 +00:00
commit 753812ad57
191 changed files with 35312 additions and 0 deletions

24
.core/build.yaml Normal file
View file

@ -0,0 +1,24 @@
version: 1
project:
name: core-api
description: REST API framework (Go + PHP)
binary: ""
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: arm64
- os: windows
arch: amd64

20
.core/release.yaml Normal file
View file

@ -0,0 +1,20 @@
version: 1
project:
name: core-api
repository: core/api
publishers: []
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
- ci

7
.gitattributes vendored Normal file
View file

@ -0,0 +1,7 @@
*.go export-ignore
go.mod export-ignore
go.sum export-ignore
cmd/ export-ignore
pkg/ export-ignore
.core/ export-ignore
src/php/tests/ export-ignore

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Binaries
core-api
*.exe
# IDE
.idea/
.vscode/
# Go
vendor/
# PHP
/vendor/
node_modules/

104
CLAUDE.md Normal file
View file

@ -0,0 +1,104 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Core API is the REST framework for the Lethean ecosystem, providing both a **Go HTTP engine** (Gin-based, with OpenAPI generation, WebSocket/SSE, ToolBridge) and a **PHP Laravel package** (rate limiting, webhooks, API key management, OpenAPI documentation). Both halves serve the same purpose in their respective stacks.
Module: `forge.lthn.ai/core/api` | Package: `lthn/api` | Licence: EUPL-1.2
## Build and Test Commands
### Go
```bash
core build # Build binary (if cmd/ has main)
go build ./... # Build library
core go test # Run all Go tests
core go test --run TestName # Run a single test
core go cov # Coverage report
core go cov --open # Open HTML coverage in browser
core go qa # Format + vet + lint + test
core go qa full # Also race detector, vuln scan, security audit
core go fmt # gofmt
core go lint # golangci-lint
core go vet # go vet
```
### PHP (from repo root)
```bash
composer test # Run all PHP tests (Pest)
composer test -- --filter=ApiKey # Single test
composer lint # Laravel Pint (PSR-12)
./vendor/bin/pint --dirty # Format changed files
```
Tests live in `src/php/src/Api/Tests/Feature/` (in-source) and `src/php/tests/` (standalone).
## Architecture
### Go Engine (root-level .go files)
`Engine` is the central type, configured via functional `Option` functions passed to `New()`:
```go
engine, _ := api.New(api.WithAddr(":8080"), api.WithCORS("*"), api.WithSwagger(...))
engine.Register(myRouteGroup)
engine.Serve(ctx)
```
**Extension interfaces** (`group.go`):
- `RouteGroup` — minimum: `Name()`, `BasePath()`, `RegisterRoutes(*gin.RouterGroup)`
- `StreamGroup` — optional: `Channels() []string` for WebSocket
- `DescribableGroup` — extends RouteGroup with `Describe() []RouteDescription` for OpenAPI
**ToolBridge** (`bridge.go`): Converts `ToolDescriptor` structs into `POST /{tool_name}` REST endpoints with auto-generated OpenAPI paths.
**Authentication** (`authentik.go`): Authentik OIDC integration + static bearer token. Permissive middleware with `RequireAuth()` / `RequireGroup()` guards.
**OpenAPI** (`openapi.go`, `export.go`, `codegen.go`): `SpecBuilder.Build()` generates OpenAPI 3.1 JSON. `SDKGenerator` wraps openapi-generator-cli for 11 languages.
**CLI** (`cmd/api/`): Registers `core api spec` and `core api sdk` commands.
### PHP Package (`src/php/`)
Three namespace roots:
| Namespace | Path | Role |
|-----------|------|------|
| `Core\Front\Api` | `src/php/src/Front/Api/` | API frontage — middleware, versioning, auto-discovered provider |
| `Core\Api` | `src/php/src/Api/` | Backend — auth, scopes, models, webhooks, OpenAPI docs |
| `Core\Website\Api` | `src/php/src/Website/Api/` | Documentation UI — controllers, Blade views, web routes |
Boot chain: `Front\Api\Boot` (auto-discovered) fires `ApiRoutesRegistering` -> `Api\Boot` registers middleware and routes.
Key services: `WebhookService`, `RateLimitService`, `IpRestrictionService`, `OpenApiBuilder`, `ApiKeyService`.
## Conventions
- **UK English** in all user-facing strings and docs (colour, organisation, unauthorised)
- **SPDX headers** in Go files: `// SPDX-License-Identifier: EUPL-1.2`
- **`declare(strict_types=1);`** in every PHP file
- **Full type hints** on all PHP parameters and return types
- **Pest syntax** for PHP tests (not PHPUnit)
- **Flux Pro** components in Livewire views; **Font Awesome** icons
- **Conventional commits**: `type(scope): description`
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
- Go test names use `_Good` / `_Bad` / `_Ugly` suffixes
## Key Dependencies
| Go module | Role |
|-----------|------|
| `forge.lthn.ai/core/cli` | CLI command registration |
| `github.com/gin-gonic/gin` | HTTP router |
| `github.com/casbin/casbin/v2` | Authorisation policies |
| `github.com/coreos/go-oidc/v3` | OIDC / Authentik |
| `go.opentelemetry.io/otel` | OpenTelemetry tracing |
PHP: `lthn/php` (Core framework), Laravel 12, `symfony/yaml`.
Go workspace: this module is part of `~/Code/go.work`. Requires Go 1.26+, PHP 8.2+.

287
LICENCE Normal file
View file

@ -0,0 +1,287 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

191
api.go Normal file
View file

@ -0,0 +1,191 @@
// SPDX-License-Identifier: EUPL-1.2
// Package api provides a Gin-based REST framework with OpenAPI generation.
// Subsystems implement RouteGroup to register their own endpoints.
package api
import (
"context"
"errors"
"iter"
"net/http"
"slices"
"time"
"github.com/gin-contrib/expvar"
"github.com/gin-contrib/pprof"
"github.com/gin-gonic/gin"
)
const defaultAddr = ":8080"
// shutdownTimeout is the maximum duration to wait for in-flight requests
// to complete during graceful shutdown.
const shutdownTimeout = 10 * time.Second
// Engine is the central API server managing route groups and middleware.
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
}
// New creates an Engine with the given options.
// The default listen address is ":8080".
func New(opts ...Option) (*Engine, error) {
e := &Engine{
addr: defaultAddr,
}
for _, opt := range opts {
opt(e)
}
return e, nil
}
// Addr returns the configured listen address.
func (e *Engine) Addr() string {
return e.addr
}
// Groups returns all registered route groups.
func (e *Engine) Groups() []RouteGroup {
return e.groups
}
// GroupsIter returns an iterator over all registered route groups.
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
return slices.Values(e.groups)
}
// Register adds a route group to the engine.
func (e *Engine) Register(group RouteGroup) {
e.groups = append(e.groups, group)
}
// Channels returns all WebSocket channel names from registered StreamGroups.
// Groups that do not implement StreamGroup are silently skipped.
func (e *Engine) Channels() []string {
var channels []string
for _, g := range e.groups {
if sg, ok := g.(StreamGroup); ok {
channels = append(channels, sg.Channels()...)
}
}
return channels
}
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
func (e *Engine) ChannelsIter() iter.Seq[string] {
return func(yield func(string) bool) {
for _, g := range e.groups {
if sg, ok := g.(StreamGroup); ok {
for _, c := range sg.Channels() {
if !yield(c) {
return
}
}
}
}
}
}
// Handler builds the Gin engine and returns it as an http.Handler.
// Each call produces a fresh handler reflecting the current set of groups.
func (e *Engine) Handler() http.Handler {
return e.build()
}
// Serve starts the HTTP server and blocks until the context is cancelled,
// then performs a graceful shutdown allowing in-flight requests to complete.
func (e *Engine) Serve(ctx context.Context) error {
srv := &http.Server{
Addr: e.addr,
Handler: e.build(),
}
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
// Block until context is cancelled.
<-ctx.Done()
// Graceful shutdown with timeout.
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
// Return any listen error that occurred before shutdown.
return <-errCh
}
// build creates a configured Gin engine with recovery middleware,
// user-supplied middleware, the health endpoint, and all registered route groups.
func (e *Engine) build() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// Apply user-supplied middleware after recovery but before routes.
for _, mw := range e.middlewares {
r.Use(mw)
}
// Built-in health check.
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, OK("healthy"))
})
// Mount each registered group at its base path.
for _, g := range e.groups {
rg := r.Group(g.BasePath())
g.RegisterRoutes(rg)
}
// Mount WebSocket handler if configured.
if e.wsHandler != nil {
r.GET("/ws", wrapWSHandler(e.wsHandler))
}
// Mount SSE endpoint if configured.
if e.sseBroker != nil {
r.GET("/events", e.sseBroker.Handler())
}
// Mount GraphQL endpoint if configured.
if e.graphql != nil {
mountGraphQL(r, e.graphql)
}
// Mount Swagger UI if enabled.
if e.swaggerEnabled {
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
}
// Mount pprof profiling endpoints if enabled.
if e.pprofEnabled {
pprof.Register(r)
}
// Mount expvar runtime metrics endpoint if enabled.
if e.expvarEnabled {
r.GET("/debug/vars", expvar.Handler())
}
return r
}

204
api_test.go Normal file
View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Test helpers ────────────────────────────────────────────────────────
// healthGroup is a minimal RouteGroup for testing Engine integration.
type healthGroup struct{}
func (h *healthGroup) Name() string { return "health-extra" }
func (h *healthGroup) BasePath() string { return "/v1" }
func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/echo", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("echo"))
})
}
// ── New ─────────────────────────────────────────────────────────────────
func TestNew_Good(t *testing.T) {
e, err := api.New()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if e == nil {
t.Fatal("expected non-nil Engine")
}
}
func TestNew_Good_WithAddr(t *testing.T) {
e, err := api.New(api.WithAddr(":9090"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if e.Addr() != ":9090" {
t.Fatalf("expected addr=%q, got %q", ":9090", e.Addr())
}
}
// ── Default address ─────────────────────────────────────────────────────
func TestAddr_Good_Default(t *testing.T) {
e, _ := api.New()
if e.Addr() != ":8080" {
t.Fatalf("expected default addr=%q, got %q", ":8080", e.Addr())
}
}
// ── Register + Groups ───────────────────────────────────────────────────
func TestRegister_Good(t *testing.T) {
e, _ := api.New()
e.Register(&healthGroup{})
groups := e.Groups()
if len(groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(groups))
}
if groups[0].Name() != "health-extra" {
t.Fatalf("expected group name=%q, got %q", "health-extra", groups[0].Name())
}
}
func TestRegister_Good_MultipleGroups(t *testing.T) {
e, _ := api.New()
e.Register(&healthGroup{})
e.Register(&stubGroup{})
if len(e.Groups()) != 2 {
t.Fatalf("expected 2 groups, got %d", len(e.Groups()))
}
}
// ── Handler ─────────────────────────────────────────────────────────────
func TestHandler_Good_HealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
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.Success {
t.Fatal("expected Success=true")
}
if resp.Data != "healthy" {
t.Fatalf("expected Data=%q, got %q", "healthy", resp.Data)
}
}
func TestHandler_Good_RegisteredRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
e.Register(&healthGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/echo", nil)
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.Data != "echo" {
t.Fatalf("expected Data=%q, got %q", "echo", resp.Data)
}
}
func TestHandler_Bad_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ── Serve + graceful shutdown ───────────────────────────────────────────
func TestServe_Good_GracefulShutdown(t *testing.T) {
// Pick a random free port to avoid conflicts.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
e, _ := api.New(api.WithAddr(addr))
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- e.Serve(ctx)
}()
// Wait for server to be ready.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
conn.Close()
break
}
time.Sleep(50 * time.Millisecond)
}
// Verify the server responds.
resp, err := http.Get("http://" + addr + "/health")
if err != nil {
t.Fatalf("health request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// Cancel context to trigger graceful shutdown.
cancel()
select {
case serveErr := <-errCh:
if serveErr != nil {
t.Fatalf("Serve returned unexpected error: %v", serveErr)
}
case <-time.After(5 * time.Second):
t.Fatal("Serve did not return within 5 seconds after context cancellation")
}
}

228
authentik.go Normal file
View file

@ -0,0 +1,228 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"context"
"net/http"
"slices"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
)
// AuthentikConfig holds settings for the Authentik forward-auth integration.
type AuthentikConfig struct {
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
Issuer string
// ClientID is the OAuth2 client identifier.
ClientID string
// TrustedProxy enables reading X-authentik-* headers set by a reverse proxy.
// When false, headers are ignored to prevent spoofing from untrusted sources.
TrustedProxy bool
// PublicPaths lists additional paths that do not require authentication.
// /health and /swagger are always public.
PublicPaths []string
}
// AuthentikUser represents an authenticated user extracted from Authentik
// forward-auth headers or a validated JWT.
type AuthentikUser struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
UID string `json:"uid"`
Groups []string `json:"groups,omitempty"`
Entitlements []string `json:"entitlements,omitempty"`
JWT string `json:"-"`
}
// HasGroup reports whether the user belongs to the named group.
func (u *AuthentikUser) HasGroup(group string) bool {
return slices.Contains(u.Groups, group)
}
// authentikUserKey is the Gin context key used to store the authenticated user.
const authentikUserKey = "authentik_user"
// GetUser retrieves the AuthentikUser from the Gin context.
// Returns nil when no user has been set (unauthenticated request or
// middleware not active).
func GetUser(c *gin.Context) *AuthentikUser {
val, exists := c.Get(authentikUserKey)
if !exists {
return nil
}
user, ok := val.(*AuthentikUser)
if !ok {
return nil
}
return user
}
// oidcProviderMu guards the provider cache.
var oidcProviderMu sync.Mutex
// oidcProviders caches OIDC providers by issuer URL to avoid repeated
// discovery requests.
var oidcProviders = make(map[string]*oidc.Provider)
// getOIDCProvider returns a cached OIDC provider for the given issuer,
// performing discovery on first access.
func getOIDCProvider(ctx context.Context, issuer string) (*oidc.Provider, error) {
oidcProviderMu.Lock()
defer oidcProviderMu.Unlock()
if p, ok := oidcProviders[issuer]; ok {
return p, nil
}
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}
oidcProviders[issuer] = p
return p, nil
}
// validateJWT verifies a raw JWT against the configured OIDC issuer and
// extracts user claims on success.
func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*AuthentikUser, error) {
provider, err := getOIDCProvider(ctx, cfg.Issuer)
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
idToken, err := verifier.Verify(ctx, rawToken)
if err != nil {
return nil, err
}
var claims struct {
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Sub string `json:"sub"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
return &AuthentikUser{
Username: claims.PreferredUsername,
Email: claims.Email,
Name: claims.Name,
UID: claims.Sub,
Groups: claims.Groups,
JWT: rawToken,
}, nil
}
// authentikMiddleware returns Gin middleware that extracts user identity from
// X-authentik-* headers set by a trusted reverse proxy (e.g. Traefik with
// Authentik forward-auth) or from a JWT in the Authorization header.
//
// The middleware is PERMISSIVE: it populates the context when credentials are
// present but never rejects unauthenticated requests. Downstream handlers
// use GetUser to check authentication.
func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
// Build the set of public paths that skip header extraction entirely.
public := map[string]bool{
"/health": true,
"/swagger": true,
}
for _, p := range cfg.PublicPaths {
public[p] = true
}
return func(c *gin.Context) {
// Skip public paths.
path := c.Request.URL.Path
for p := range public {
if strings.HasPrefix(path, p) {
c.Next()
return
}
}
// Block 1: Extract user from X-authentik-* forward-auth headers.
if cfg.TrustedProxy {
username := c.GetHeader("X-authentik-username")
if username != "" {
user := &AuthentikUser{
Username: username,
Email: c.GetHeader("X-authentik-email"),
Name: c.GetHeader("X-authentik-name"),
UID: c.GetHeader("X-authentik-uid"),
JWT: c.GetHeader("X-authentik-jwt"),
}
if groups := c.GetHeader("X-authentik-groups"); groups != "" {
user.Groups = strings.Split(groups, "|")
}
if ent := c.GetHeader("X-authentik-entitlements"); ent != "" {
user.Entitlements = strings.Split(ent, "|")
}
c.Set(authentikUserKey, user)
}
}
// Block 2: Attempt JWT validation for direct API clients.
// Only when OIDC is configured and no user was extracted from headers.
if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil {
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
rawToken := strings.TrimPrefix(auth, "Bearer ")
if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil {
c.Set(authentikUserKey, user)
}
// On failure: continue without user (fail open / permissive).
}
}
c.Next()
}
}
// RequireAuth is Gin middleware that rejects unauthenticated requests.
// It checks for a user set by the Authentik middleware and returns 401
// when none is present.
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if GetUser(c) == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized,
Fail("unauthorised", "Authentication required"))
return
}
c.Next()
}
}
// RequireGroup is Gin middleware that rejects requests from users who do
// not belong to the specified group. Returns 401 when no user is present
// and 403 when the user lacks the required group membership.
func RequireGroup(group string) gin.HandlerFunc {
return func(c *gin.Context) {
user := GetUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized,
Fail("unauthorised", "Authentication required"))
return
}
if !user.HasGroup(group) {
c.AbortWithStatusJSON(http.StatusForbidden,
Fail("forbidden", "Insufficient permissions"))
return
}
c.Next()
}
}

View file

@ -0,0 +1,337 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
api "forge.lthn.ai/core/api"
"github.com/gin-gonic/gin"
)
// testAuthRoutes provides endpoints for integration testing.
type testAuthRoutes struct{}
func (r *testAuthRoutes) Name() string { return "authtest" }
func (r *testAuthRoutes) BasePath() string { return "/v1" }
func (r *testAuthRoutes) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/public", func(c *gin.Context) {
c.JSON(200, api.OK("public"))
})
rg.GET("/whoami", api.RequireAuth(), func(c *gin.Context) {
user := api.GetUser(c)
c.JSON(200, api.OK(user))
})
rg.GET("/admin", api.RequireGroup("admins"), func(c *gin.Context) {
user := api.GetUser(c)
c.JSON(200, api.OK(user))
})
}
// getClientCredentialsToken fetches a token from Authentik using
// the client_credentials grant.
func getClientCredentialsToken(t *testing.T, issuer, clientID, clientSecret string) (accessToken, idToken string) {
t.Helper()
// Discover token endpoint.
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
resp, err := http.Get(disc)
if err != nil {
t.Fatalf("OIDC discovery failed: %v", err)
}
defer resp.Body.Close()
var config struct {
TokenEndpoint string `json:"token_endpoint"`
}
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
t.Fatalf("decode discovery: %v", err)
}
// Request token.
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {clientID},
"client_secret": {clientSecret},
"scope": {"openid email profile entitlements"},
}
resp, err = http.PostForm(config.TokenEndpoint, data)
if err != nil {
t.Fatalf("token request failed: %v", err)
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
t.Fatalf("decode token response: %v", err)
}
if tokenResp.Error != "" {
t.Fatalf("token error: %s — %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return tokenResp.AccessToken, tokenResp.IDToken
}
func TestAuthentikIntegration(t *testing.T) {
// Skip unless explicitly enabled — requires live Authentik at auth.lthn.io.
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
}
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
clientID := envOr("AUTHENTIK_CLIENT_ID", "core-api")
clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET")
if clientSecret == "" {
t.Fatal("AUTHENTIK_CLIENT_SECRET is required")
}
gin.SetMode(gin.TestMode)
// Fetch a real token from Authentik.
t.Run("TokenAcquisition", func(t *testing.T) {
access, id := getClientCredentialsToken(t, issuer, clientID, clientSecret)
if access == "" {
t.Fatal("empty access_token")
}
if id == "" {
t.Fatal("empty id_token")
}
t.Logf("access_token length: %d", len(access))
t.Logf("id_token length: %d", len(id))
})
// Build the engine with real Authentik config.
engine, err := api.New(
api.WithAuthentik(api.AuthentikConfig{
Issuer: issuer,
ClientID: clientID,
TrustedProxy: true,
}),
)
if err != nil {
t.Fatalf("engine: %v", err)
}
engine.Register(&testAuthRoutes{})
ts := httptest.NewServer(engine.Handler())
defer ts.Close()
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
t.Run("Health_NoAuth", func(t *testing.T) {
resp := get(t, ts.URL+"/health", "")
assertStatus(t, resp, 200)
body := readBody(t, resp)
t.Logf("health: %s", body)
})
t.Run("Public_NoAuth", func(t *testing.T) {
resp := get(t, ts.URL+"/v1/public", "")
assertStatus(t, resp, 200)
body := readBody(t, resp)
t.Logf("public: %s", body)
})
t.Run("Whoami_NoToken_401", func(t *testing.T) {
resp := get(t, ts.URL+"/v1/whoami", "")
assertStatus(t, resp, 401)
})
t.Run("Whoami_WithAccessToken", func(t *testing.T) {
resp := get(t, ts.URL+"/v1/whoami", accessToken)
assertStatus(t, resp, 200)
body := readBody(t, resp)
t.Logf("whoami (access_token): %s", body)
// Parse response and verify user fields.
var envelope struct {
Data api.AuthentikUser `json:"data"`
}
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
t.Fatalf("parse whoami: %v", err)
}
if envelope.Data.UID == "" {
t.Error("expected non-empty UID")
}
if !strings.Contains(envelope.Data.Username, "client_credentials") {
t.Logf("username: %s (service account)", envelope.Data.Username)
}
})
t.Run("Admin_ServiceAccount_403", func(t *testing.T) {
// Service account has no groups — should get 403.
resp := get(t, ts.URL+"/v1/admin", accessToken)
assertStatus(t, resp, 403)
})
t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) {
// Simulate what Traefik sends after forward auth.
req, _ := http.NewRequest("GET", ts.URL+"/v1/whoami", nil)
req.Header.Set("X-authentik-username", "akadmin")
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
req.Header.Set("X-authentik-name", "Admin User")
req.Header.Set("X-authentik-uid", "abc123")
req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers")
req.Header.Set("X-authentik-entitlements", "")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
assertStatus(t, resp, 200)
body := readBody(t, resp)
t.Logf("whoami (forward auth): %s", body)
var envelope struct {
Data api.AuthentikUser `json:"data"`
}
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
t.Fatalf("parse: %v", err)
}
if envelope.Data.Username != "akadmin" {
t.Errorf("expected username akadmin, got %s", envelope.Data.Username)
}
if !envelope.Data.HasGroup("admins") {
t.Error("expected admins group")
}
})
t.Run("Admin_ForwardAuth_Admins_200", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/v1/admin", nil)
req.Header.Set("X-authentik-username", "akadmin")
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
req.Header.Set("X-authentik-name", "Admin User")
req.Header.Set("X-authentik-uid", "abc123")
req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
assertStatus(t, resp, 200)
t.Logf("admin (forward auth): %s", readBody(t, resp))
})
t.Run("InvalidJWT_FailOpen", func(t *testing.T) {
// Invalid token on a public endpoint — should still work (permissive).
resp := get(t, ts.URL+"/v1/public", "not-a-real-token")
assertStatus(t, resp, 200)
})
t.Run("InvalidJWT_Protected_401", func(t *testing.T) {
// Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401.
resp := get(t, ts.URL+"/v1/whoami", "not-a-real-token")
assertStatus(t, resp, 401)
})
}
func get(t *testing.T, url, bearerToken string) *http.Response {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
func assertStatus(t *testing.T, resp *http.Response, want int) {
t.Helper()
if resp.StatusCode != want {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("want status %d, got %d: %s", want, resp.StatusCode, string(b))
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// TestOIDCDiscovery validates that the OIDC discovery endpoint is reachable.
func TestOIDCDiscovery(t *testing.T) {
if os.Getenv("AUTHENTIK_INTEGRATION") != "1" {
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
}
issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
resp, err := http.Get(disc)
if err != nil {
t.Fatalf("discovery request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("discovery status: %d", resp.StatusCode)
}
var config map[string]any
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
t.Fatalf("decode: %v", err)
}
// Verify essential fields.
for _, field := range []string{"issuer", "token_endpoint", "jwks_uri", "authorization_endpoint"} {
if config[field] == nil {
t.Errorf("missing field: %s", field)
}
}
if config["issuer"] != issuer {
t.Errorf("issuer mismatch: got %v, want %s", config["issuer"], issuer)
}
// Verify grant types include client_credentials.
grants, ok := config["grant_types_supported"].([]any)
if !ok {
t.Fatal("missing grant_types_supported")
}
found := false
for _, g := range grants {
if g == "client_credentials" {
found = true
break
}
}
if !found {
t.Error("client_credentials grant not supported")
}
fmt.Printf(" OIDC discovery OK — issuer: %s\n", config["issuer"])
fmt.Printf(" Token endpoint: %s\n", config["token_endpoint"])
fmt.Printf(" JWKS URI: %s\n", config["jwks_uri"])
}

460
authentik_test.go Normal file
View file

@ -0,0 +1,460 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── AuthentikUser ──────────────────────────────────────────────────────
func TestAuthentikUser_Good(t *testing.T) {
u := api.AuthentikUser{
Username: "alice",
Email: "alice@example.com",
Name: "Alice Smith",
UID: "abc-123",
Groups: []string{"editors", "admins"},
Entitlements: []string{"premium"},
JWT: "tok.en.here",
}
if u.Username != "alice" {
t.Fatalf("expected Username=%q, got %q", "alice", u.Username)
}
if u.Email != "alice@example.com" {
t.Fatalf("expected Email=%q, got %q", "alice@example.com", u.Email)
}
if u.Name != "Alice Smith" {
t.Fatalf("expected Name=%q, got %q", "Alice Smith", u.Name)
}
if u.UID != "abc-123" {
t.Fatalf("expected UID=%q, got %q", "abc-123", u.UID)
}
if len(u.Groups) != 2 || u.Groups[0] != "editors" {
t.Fatalf("expected Groups=[editors admins], got %v", u.Groups)
}
if len(u.Entitlements) != 1 || u.Entitlements[0] != "premium" {
t.Fatalf("expected Entitlements=[premium], got %v", u.Entitlements)
}
if u.JWT != "tok.en.here" {
t.Fatalf("expected JWT=%q, got %q", "tok.en.here", u.JWT)
}
}
func TestAuthentikUserHasGroup_Good(t *testing.T) {
u := api.AuthentikUser{
Groups: []string{"editors", "admins"},
}
if !u.HasGroup("admins") {
t.Fatal("expected HasGroup(admins) = true")
}
if !u.HasGroup("editors") {
t.Fatal("expected HasGroup(editors) = true")
}
}
func TestAuthentikUserHasGroup_Bad_Empty(t *testing.T) {
u := api.AuthentikUser{}
if u.HasGroup("admins") {
t.Fatal("expected HasGroup(admins) = false for empty user")
}
}
func TestAuthentikConfig_Good(t *testing.T) {
cfg := api.AuthentikConfig{
Issuer: "https://auth.example.com",
ClientID: "my-client",
TrustedProxy: true,
PublicPaths: []string{"/public", "/docs"},
}
if cfg.Issuer != "https://auth.example.com" {
t.Fatalf("expected Issuer=%q, got %q", "https://auth.example.com", cfg.Issuer)
}
if cfg.ClientID != "my-client" {
t.Fatalf("expected ClientID=%q, got %q", "my-client", cfg.ClientID)
}
if !cfg.TrustedProxy {
t.Fatal("expected TrustedProxy=true")
}
if len(cfg.PublicPaths) != 2 {
t.Fatalf("expected 2 public paths, got %d", len(cfg.PublicPaths))
}
}
// ── Forward auth middleware ────────────────────────────────────────────
func TestForwardAuthHeaders_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
req.Header.Set("X-authentik-username", "bob")
req.Header.Set("X-authentik-email", "bob@example.com")
req.Header.Set("X-authentik-name", "Bob Jones")
req.Header.Set("X-authentik-uid", "uid-456")
req.Header.Set("X-authentik-jwt", "jwt.tok.en")
req.Header.Set("X-authentik-groups", "staff|admins|ops")
req.Header.Set("X-authentik-entitlements", "read|write")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUser == nil {
t.Fatal("expected GetUser to return a user, got nil")
}
if gotUser.Username != "bob" {
t.Fatalf("expected Username=%q, got %q", "bob", gotUser.Username)
}
if gotUser.Email != "bob@example.com" {
t.Fatalf("expected Email=%q, got %q", "bob@example.com", gotUser.Email)
}
if gotUser.Name != "Bob Jones" {
t.Fatalf("expected Name=%q, got %q", "Bob Jones", gotUser.Name)
}
if gotUser.UID != "uid-456" {
t.Fatalf("expected UID=%q, got %q", "uid-456", gotUser.UID)
}
if gotUser.JWT != "jwt.tok.en" {
t.Fatalf("expected JWT=%q, got %q", "jwt.tok.en", gotUser.JWT)
}
if len(gotUser.Groups) != 3 {
t.Fatalf("expected 3 groups, got %d: %v", len(gotUser.Groups), gotUser.Groups)
}
if gotUser.Groups[0] != "staff" || gotUser.Groups[1] != "admins" || gotUser.Groups[2] != "ops" {
t.Fatalf("expected groups [staff admins ops], got %v", gotUser.Groups)
}
if len(gotUser.Entitlements) != 2 {
t.Fatalf("expected 2 entitlements, got %d: %v", len(gotUser.Entitlements), gotUser.Entitlements)
}
if gotUser.Entitlements[0] != "read" || gotUser.Entitlements[1] != "write" {
t.Fatalf("expected entitlements [read write], got %v", gotUser.Entitlements)
}
}
func TestForwardAuthHeaders_Good_NoHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil without headers, got %+v", gotUser)
}
}
func TestForwardAuthHeaders_Bad_NotTrusted(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: false}
e, _ := api.New(api.WithAuthentik(cfg))
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
req.Header.Set("X-authentik-username", "mallory")
req.Header.Set("X-authentik-email", "mallory@evil.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil when TrustedProxy=false, got %+v", gotUser)
}
}
func TestHealthBypassesAuthentik_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for /health, got %d", w.Code)
}
}
func TestGetUser_Good_NilContext(t *testing.T) {
gin.SetMode(gin.TestMode)
// Engine without WithAuthentik — GetUser should return nil.
e, _ := api.New()
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil without middleware, got %+v", gotUser)
}
}
// ── JWT validation ────────────────────────────────────────────────────
func TestJWTValidation_Bad_InvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use a fake issuer that won't resolve — JWT validation should fail open.
cfg := api.AuthentikConfig{
Issuer: "https://fake-issuer.invalid",
ClientID: "test-client",
}
e, _ := api.New(api.WithAuthentik(cfg))
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
req.Header.Set("Authorization", "Bearer invalid-jwt-token")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (permissive), got %d", w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil for invalid JWT, got %+v", gotUser)
}
}
func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
// Engine with BOTH bearer auth AND authentik middleware.
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(
api.WithBearerAuth("secret-token"),
api.WithAuthentik(cfg),
)
var gotUser *api.AuthentikUser
e.Register(&authTestGroup{onRequest: func(c *gin.Context) {
gotUser = api.GetUser(c)
c.JSON(http.StatusOK, api.OK("ok"))
}})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil)
req.Header.Set("Authorization", "Bearer secret-token")
req.Header.Set("X-authentik-username", "carol")
req.Header.Set("X-authentik-email", "carol@example.com")
req.Header.Set("X-authentik-name", "Carol White")
req.Header.Set("X-authentik-uid", "uid-789")
req.Header.Set("X-authentik-groups", "developers|admins")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUser == nil {
t.Fatal("expected GetUser to return a user, got nil")
}
if gotUser.Username != "carol" {
t.Fatalf("expected Username=%q, got %q", "carol", gotUser.Username)
}
if gotUser.Email != "carol@example.com" {
t.Fatalf("expected Email=%q, got %q", "carol@example.com", gotUser.Email)
}
if len(gotUser.Groups) != 2 || gotUser.Groups[0] != "developers" || gotUser.Groups[1] != "admins" {
t.Fatalf("expected groups [developers admins], got %v", gotUser.Groups)
}
}
// ── RequireAuth / RequireGroup ────────────────────────────────────────
func TestRequireAuth_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
req.Header.Set("X-authentik-username", "alice")
req.Header.Set("X-authentik-email", "alice@example.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireAuth_Bad_NoUser(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"unauthorised"`) {
t.Fatalf("expected error code 'unauthorised' in body, got %s", body)
}
}
func TestRequireAuth_Bad_NoAuthentikMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
// Engine without WithAuthentik — RequireAuth should still reject.
e, _ := api.New()
e.Register(&protectedGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireGroup_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&groupRequireGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "admin-user")
req.Header.Set("X-authentik-groups", "admins|staff")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestRequireGroup_Bad_WrongGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&groupRequireGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil)
req.Header.Set("X-authentik-username", "dev-user")
req.Header.Set("X-authentik-groups", "developers")
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"forbidden"`) {
t.Fatalf("expected error code 'forbidden' in body, got %s", body)
}
}
// ── Test helpers ───────────────────────────────────────────────────────
// authTestGroup provides a /v1/check endpoint that calls a custom handler.
type authTestGroup struct {
onRequest func(c *gin.Context)
}
func (a *authTestGroup) Name() string { return "auth-test" }
func (a *authTestGroup) BasePath() string { return "/v1" }
func (a *authTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/check", a.onRequest)
}
// protectedGroup provides a /v1/protected/data endpoint guarded by RequireAuth.
type protectedGroup struct{}
func (g *protectedGroup) Name() string { return "protected" }
func (g *protectedGroup) BasePath() string { return "/v1/protected" }
func (g *protectedGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/data", api.RequireAuth(), func(c *gin.Context) {
user := api.GetUser(c)
c.JSON(200, api.OK(user.Username))
})
}
// groupRequireGroup provides a /v1/admin/panel endpoint guarded by RequireGroup.
type groupRequireGroup struct{}
func (g *groupRequireGroup) Name() string { return "adminonly" }
func (g *groupRequireGroup) BasePath() string { return "/v1/admin" }
func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/panel", api.RequireGroup("admins"), func(c *gin.Context) {
c.JSON(200, api.OK("admin panel"))
})
}

222
authz_test.go Normal file
View file

@ -0,0 +1,222 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// casbinModel is a minimal RESTful ACL model for testing authorisation.
const casbinModel = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && r.act == p.act
`
// newTestEnforcer creates a Casbin enforcer from the inline model and adds
// the given policies programmatically. Each policy is a [subject, object, action] triple.
func newTestEnforcer(t *testing.T, policies [][3]string) *casbin.Enforcer {
t.Helper()
m, err := model.NewModelFromString(casbinModel)
if err != nil {
t.Fatalf("failed to create casbin model: %v", err)
}
e, err := casbin.NewEnforcer(m)
if err != nil {
t.Fatalf("failed to create casbin enforcer: %v", err)
}
for _, p := range policies {
if _, err := e.AddPolicy(p[0], p[1], p[2]); err != nil {
t.Fatalf("failed to add policy %v: %v", p, err)
}
}
return e
}
// setBasicAuth sets the HTTP Basic Authentication header on a request.
func setBasicAuth(req *http.Request, user, pass string) {
req.SetBasicAuth(user, pass)
}
// ── WithAuthz ─────────────────────────────────────────────────────────────
func TestWithAuthz_Good_AllowsPermittedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
enforcer := newTestEnforcer(t, [][3]string{
{"alice", "/stub/*", "GET"},
})
e, _ := api.New(api.WithAuthz(enforcer))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for permitted request, got %d", w.Code)
}
}
func TestWithAuthz_Bad_DeniesUnpermittedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
// Only alice is permitted; bob has no policy entry.
enforcer := newTestEnforcer(t, [][3]string{
{"alice", "/stub/*", "GET"},
})
e, _ := api.New(api.WithAuthz(enforcer))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
setBasicAuth(req, "bob", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for unpermitted request, got %d", w.Code)
}
}
func TestWithAuthz_Good_DifferentMethodsEvaluatedSeparately(t *testing.T) {
gin.SetMode(gin.TestMode)
// alice can GET but not DELETE.
enforcer := newTestEnforcer(t, [][3]string{
{"alice", "/stub/*", "GET"},
})
e, _ := api.New(api.WithAuthz(enforcer))
e.Register(&stubGroup{})
h := e.Handler()
// GET should succeed.
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for GET, got %d", w.Code)
}
// DELETE should be denied (no policy for DELETE).
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodDelete, "/stub/ping", nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for DELETE, got %d", w.Code)
}
}
func TestWithAuthz_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
enforcer := newTestEnforcer(t, [][3]string{
{"alice", "/stub/*", "GET"},
})
e, _ := api.New(
api.WithRequestID(),
api.WithAuthz(enforcer),
)
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Both authz (allowed) and request ID should be active.
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
// casbinWildcardModel extends the base model with a matcher that treats
// "*" as a wildcard subject, allowing any authenticated user through.
const casbinWildcardModel = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = (r.sub == p.sub || p.sub == "*") && keyMatch(r.obj, p.obj) && r.act == p.act
`
func TestWithAuthz_Ugly_WildcardPolicyAllowsAll(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use a model whose matcher treats "*" as a wildcard subject.
m, err := model.NewModelFromString(casbinWildcardModel)
if err != nil {
t.Fatalf("failed to create casbin model: %v", err)
}
enforcer, err := casbin.NewEnforcer(m)
if err != nil {
t.Fatalf("failed to create casbin enforcer: %v", err)
}
if _, err := enforcer.AddPolicy("*", "/stub/*", "GET"); err != nil {
t.Fatalf("failed to add wildcard policy: %v", err)
}
e, _ := api.New(api.WithAuthz(enforcer))
e.Register(&stubGroup{})
h := e.Handler()
// Any user should be allowed by the wildcard policy.
for _, user := range []string{"alice", "bob", "charlie"} {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
setBasicAuth(req, user, "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for user %q with wildcard policy, got %d", user, w.Code)
}
}
}

122
bridge.go Normal file
View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"iter"
"github.com/gin-gonic/gin"
)
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
type ToolDescriptor struct {
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
Description string // Human-readable description
Group string // OpenAPI tag group, e.g. "files"
InputSchema map[string]any // JSON Schema for request body
OutputSchema map[string]any // JSON Schema for response data (optional)
}
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
// It implements both RouteGroup and DescribableGroup.
type ToolBridge struct {
basePath string
name string
tools []boundTool
}
type boundTool struct {
descriptor ToolDescriptor
handler gin.HandlerFunc
}
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
func NewToolBridge(basePath string) *ToolBridge {
return &ToolBridge{
basePath: basePath,
name: "tools",
}
}
// Add registers a tool with its HTTP handler.
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
}
// Name returns the bridge identifier.
func (b *ToolBridge) Name() string { return b.name }
// BasePath returns the URL prefix for all tool endpoints.
func (b *ToolBridge) BasePath() string { return b.basePath }
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
for _, t := range b.tools {
rg.POST("/"+t.descriptor.Name, t.handler)
}
}
// Describe returns OpenAPI route descriptions for all registered tools.
func (b *ToolBridge) Describe() []RouteDescription {
descs := make([]RouteDescription, 0, len(b.tools))
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
}
descs = append(descs, RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
})
}
return descs
}
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
return func(yield func(RouteDescription) bool) {
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
}
rd := RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
}
if !yield(rd) {
return
}
}
}
}
// Tools returns all registered tool descriptors.
func (b *ToolBridge) Tools() []ToolDescriptor {
descs := make([]ToolDescriptor, len(b.tools))
for i, t := range b.tools {
descs[i] = t.descriptor
}
return descs
}
// ToolsIter returns an iterator over all registered tool descriptors.
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
return func(yield func(ToolDescriptor) bool) {
for _, t := range b.tools {
if !yield(t.descriptor) {
return
}
}
}
}

234
bridge_test.go Normal file
View file

@ -0,0 +1,234 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── ToolBridge ─────────────────────────────────────────────────────────
func TestToolBridge_Good_RegisterAndServe(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",
Group: "files",
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("result1"))
})
bridge.Add(api.ToolDescriptor{
Name: "file_write",
Description: "Write a file",
Group: "files",
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("result2"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
// POST /tools/file_read
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
engine.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200 for file_read, 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.Data != "result1" {
t.Fatalf("expected Data=%q, got %q", "result1", resp1.Data)
}
// POST /tools/file_write
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodPost, "/tools/file_write", nil)
engine.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 for file_write, 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.Data != "result2" {
t.Fatalf("expected Data=%q, got %q", "result2", resp2.Data)
}
}
func TestToolBridge_Good_BasePath(t *testing.T) {
bridge := api.NewToolBridge("/api/v1/tools")
if bridge.BasePath() != "/api/v1/tools" {
t.Fatalf("expected BasePath=%q, got %q", "/api/v1/tools", bridge.BasePath())
}
if bridge.Name() != "tools" {
t.Fatalf("expected Name=%q, got %q", "tools", bridge.Name())
}
}
func TestToolBridge_Good_Describe(t *testing.T) {
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"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {})
// Verify DescribableGroup interface satisfaction.
var dg api.DescribableGroup = bridge
descs := dg.Describe()
if len(descs) != 2 {
t.Fatalf("expected 2 descriptions, got %d", len(descs))
}
// First tool.
if descs[0].Method != "POST" {
t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method)
}
if descs[0].Path != "/file_read" {
t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path)
}
if descs[0].Summary != "Read a file from disk" {
t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary)
}
if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" {
t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags)
}
if descs[0].RequestBody == nil {
t.Fatal("expected descs[0].RequestBody to be non-nil")
}
if descs[0].Response == nil {
t.Fatal("expected descs[0].Response to be non-nil")
}
// Second tool.
if descs[1].Path != "/metrics_query" {
t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path)
}
if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" {
t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags)
}
if descs[1].Response != nil {
t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response)
}
}
func TestToolBridge_Good_ToolsAccessor(t *testing.T) {
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})
bridge.Add(api.ToolDescriptor{Name: "beta", Description: "Tool B", Group: "b"}, func(c *gin.Context) {})
bridge.Add(api.ToolDescriptor{Name: "gamma", Description: "Tool C", Group: "c"}, func(c *gin.Context) {})
tools := bridge.Tools()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d", len(tools))
}
expected := []string{"alpha", "beta", "gamma"}
for i, want := range expected {
if tools[i].Name != want {
t.Fatalf("expected tools[%d].Name=%q, got %q", i, want, tools[i].Name)
}
}
}
func TestToolBridge_Bad_EmptyBridge(t *testing.T) {
gin.SetMode(gin.TestMode)
bridge := api.NewToolBridge("/tools")
// RegisterRoutes should not panic with no tools.
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
// Describe should return empty slice.
descs := bridge.Describe()
if len(descs) != 0 {
t.Fatalf("expected 0 descriptions, got %d", len(descs))
}
// Tools should return empty slice.
tools := bridge.Tools()
if len(tools) != 0 {
t.Fatalf("expected 0 tools, got %d", len(tools))
}
}
func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "ping",
Description: "Ping tool",
Group: "util",
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("pong"))
})
e.Register(bridge)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/ping", nil)
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.Success {
t.Fatal("expected Success=true")
}
if resp.Data != "pong" {
t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
}
}

120
brotli.go Normal file
View file

@ -0,0 +1,120 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/andybalholm/brotli"
"github.com/gin-gonic/gin"
)
const (
// BrotliBestSpeed is the lowest (fastest) Brotli compression level.
BrotliBestSpeed = brotli.BestSpeed
// BrotliBestCompression is the highest (smallest output) Brotli level.
BrotliBestCompression = brotli.BestCompression
// BrotliDefaultCompression is the default Brotli compression level.
BrotliDefaultCompression = brotli.DefaultCompression
)
// brotliHandler manages a pool of brotli writers for reuse across requests.
type brotliHandler struct {
pool sync.Pool
level int
}
// newBrotliHandler creates a handler that pools brotli writers at the given level.
func newBrotliHandler(level int) *brotliHandler {
if level < BrotliBestSpeed || level > BrotliBestCompression {
level = BrotliDefaultCompression
}
return &brotliHandler{
level: level,
pool: sync.Pool{
New: func() any {
return brotli.NewWriterLevel(io.Discard, level)
},
},
}
}
// Handle is the Gin middleware function that compresses responses with Brotli.
func (h *brotliHandler) Handle(c *gin.Context) {
if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
c.Next()
return
}
w := h.pool.Get().(*brotli.Writer)
w.Reset(c.Writer)
c.Header("Content-Encoding", "br")
c.Writer.Header().Add("Vary", "Accept-Encoding")
bw := &brotliWriter{ResponseWriter: c.Writer, writer: w}
c.Writer = bw
defer func() {
if bw.status >= http.StatusBadRequest {
bw.Header().Del("Content-Encoding")
bw.Header().Del("Vary")
w.Reset(io.Discard)
} else if c.Writer.Size() < 0 {
w.Reset(io.Discard)
}
_ = w.Close()
if c.Writer.Size() > -1 {
c.Header("Content-Length", strconv.Itoa(c.Writer.Size()))
}
h.pool.Put(w)
}()
c.Next()
}
// brotliWriter wraps gin.ResponseWriter to intercept writes through brotli.
type brotliWriter struct {
gin.ResponseWriter
writer *brotli.Writer
statusWritten bool
status int
}
func (b *brotliWriter) Write(data []byte) (int, error) {
b.Header().Del("Content-Length")
if !b.statusWritten {
b.status = b.ResponseWriter.Status()
}
if b.status >= http.StatusBadRequest {
b.Header().Del("Content-Encoding")
b.Header().Del("Vary")
return b.ResponseWriter.Write(data)
}
return b.writer.Write(data)
}
func (b *brotliWriter) WriteString(s string) (int, error) {
return b.Write([]byte(s))
}
func (b *brotliWriter) WriteHeader(code int) {
b.status = code
b.statusWritten = true
b.Header().Del("Content-Length")
b.ResponseWriter.WriteHeader(code)
}
func (b *brotliWriter) Flush() {
_ = b.writer.Flush()
b.ResponseWriter.Flush()
}

132
brotli_test.go Normal file
View file

@ -0,0 +1,132 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── WithBrotli ────────────────────────────────────────────────────────
func TestWithBrotli_Good_CompressesResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBrotli())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce)
}
}
func TestWithBrotli_Good_NoCompressionWithoutAcceptHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBrotli())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Deliberately not setting Accept-Encoding header.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce == "br" {
t.Fatal("expected no br Content-Encoding when client does not request it")
}
}
func TestWithBrotli_Good_DefaultLevel(t *testing.T) {
// Calling WithBrotli() with no arguments should use default compression
// and not panic.
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBrotli())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q with default level, got %q", "br", ce)
}
}
func TestWithBrotli_Good_CustomLevel(t *testing.T) {
// WithBrotli(BrotliBestSpeed) should work without panicking and still compress.
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBrotli(api.BrotliBestSpeed))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "br", ce)
}
}
func TestWithBrotli_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithBrotli(),
api.WithRequestID(),
)
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Both brotli compression and request ID should be present.
ce := w.Header().Get("Content-Encoding")
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce)
}
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}

126
cache.go Normal file
View file

@ -0,0 +1,126 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bytes"
"maps"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// cacheEntry holds a cached response body, status code, headers, and expiry.
type cacheEntry struct {
status int
headers http.Header
body []byte
expires time.Time
}
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
type cacheStore struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
// newCacheStore creates an empty cache store.
func newCacheStore() *cacheStore {
return &cacheStore{
entries: make(map[string]*cacheEntry),
}
}
// get retrieves a non-expired entry for the given key.
// Returns nil if the key is missing or expired.
func (s *cacheStore) get(key string) *cacheEntry {
s.mu.RLock()
entry, ok := s.entries[key]
s.mu.RUnlock()
if !ok {
return nil
}
if time.Now().After(entry.expires) {
s.mu.Lock()
delete(s.entries, key)
s.mu.Unlock()
return nil
}
return entry
}
// set stores a cache entry with the given TTL.
func (s *cacheStore) set(key string, entry *cacheEntry) {
s.mu.Lock()
s.entries[key] = entry
s.mu.Unlock()
}
// cacheWriter intercepts writes to capture the response body and status.
type cacheWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *cacheWriter) Write(data []byte) (int, error) {
w.body.Write(data)
return w.ResponseWriter.Write(data)
}
func (w *cacheWriter) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
// cacheMiddleware returns Gin middleware that caches GET responses in memory.
// Only successful responses (2xx) are cached. Non-GET methods pass through.
func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests.
if c.Request.Method != http.MethodGet {
c.Next()
return
}
key := c.Request.URL.RequestURI()
// Serve from cache if a valid entry exists.
if entry := store.get(key); entry != nil {
for k, vals := range entry.headers {
for _, v := range vals {
c.Writer.Header().Set(k, v)
}
}
c.Writer.Header().Set("X-Cache", "HIT")
c.Writer.WriteHeader(entry.status)
_, _ = c.Writer.Write(entry.body)
c.Abort()
return
}
// Wrap the writer to capture the response.
cw := &cacheWriter{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = cw
c.Next()
// Only cache successful responses.
status := cw.ResponseWriter.Status()
if status >= 200 && status < 300 {
headers := make(http.Header)
maps.Copy(headers, cw.ResponseWriter.Header())
store.set(key, &cacheEntry{
status: status,
headers: headers,
body: cw.body.Bytes(),
expires: time.Now().Add(ttl),
})
}
}
}

252
cache_test.go Normal file
View file

@ -0,0 +1,252 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// cacheCounterGroup registers routes that increment a counter on each call,
// allowing tests to distinguish cached from uncached responses.
type cacheCounterGroup struct {
counter atomic.Int64
}
func (g *cacheCounterGroup) Name() string { return "cache-test" }
func (g *cacheCounterGroup) BasePath() string { return "/cache" }
func (g *cacheCounterGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/counter", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("call-%d", n)))
})
rg.GET("/other", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("other-%d", n)))
})
rg.POST("/counter", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("post-%d", n)))
})
}
// ── WithCache ───────────────────────────────────────────────────────────
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// First request — cache MISS.
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)
}
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Second request — should be a cache HIT returning the same body.
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)
}
body2 := w2.Body.String()
if body1 != body2 {
t.Fatalf("expected cached body %q, got %q", body1, body2)
}
cacheHeader := w2.Header().Get("X-Cache")
if cacheHeader != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", cacheHeader)
}
// Counter should still be 1 (handler was not called again).
if grp.counter.Load() != 1 {
t.Fatalf("expected counter=1 (cached), got %d", grp.counter.Load())
}
}
func TestWithCache_Good_POSTNotCached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// First POST request.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil)
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.Data != "post-1" {
t.Fatalf("expected Data=%q, got %q", "post-1", resp1.Data)
}
// Second POST request — should NOT be cached, counter increments.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil)
h.ServeHTTP(w2, req2)
var resp2 api.Response[string]
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp2.Data != "post-2" {
t.Fatalf("expected Data=%q, got %q", "post-2", resp2.Data)
}
// Counter should be 2 — both POST requests hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
}
func TestWithCache_Good_DifferentPathsSeparatelyCached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(grp)
h := e.Handler()
// Request to /cache/counter.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Request to /cache/other — different path, should miss cache.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil)
h.ServeHTTP(w2, req2)
body2 := w2.Body.String()
if !strings.Contains(body2, "other-2") {
t.Fatalf("expected body to contain %q, got %q", "other-2", body2)
}
// Counter is 2 — both paths hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
// Re-request /cache/counter — should serve cached "call-1".
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w3, req3)
body3 := w3.Body.String()
if body1 != body3 {
t.Fatalf("expected cached body %q, got %q", body1, body3)
}
// Counter unchanged — served from cache.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2 (cached), got %d", grp.counter.Load())
}
}
func TestWithCache_Good_CombinesWithOtherMiddleware(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()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// RequestID middleware should still set X-Request-ID.
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
// Body should contain the expected response.
body := w.Body.String()
if !strings.Contains(body, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body)
}
}
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(50 * time.Millisecond))
e.Register(grp)
h := e.Handler()
// First request — populates cache.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
body1 := w1.Body.String()
if !strings.Contains(body1, "call-1") {
t.Fatalf("expected body to contain %q, got %q", "call-1", body1)
}
// Wait for cache to expire.
time.Sleep(100 * time.Millisecond)
// Second request — cache expired, handler called again.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w2, req2)
body2 := w2.Body.String()
if !strings.Contains(body2, "call-2") {
t.Fatalf("expected body to contain %q after expiry, got %q", "call-2", body2)
}
// Counter should be 2 — both requests hit the handler.
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
}

18
cmd/api/cmd.go Normal file
View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "forge.lthn.ai/core/cli/pkg/cli"
func init() {
cli.RegisterCommands(AddAPICommands)
}
// AddAPICommands registers the 'api' command group.
func AddAPICommands(root *cli.Command) {
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
root.AddCommand(apiCmd)
addSpecCommand(apiCmd)
addSDKCommand(apiCmd)
}

89
cmd/api/cmd_sdk.go Normal file
View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"context"
"fmt"
"os"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
goapi "forge.lthn.ai/core/api"
)
func addSDKCommand(parent *cli.Command) {
var (
lang string
output string
specFile string
packageName string
)
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
if lang == "" {
return fmt.Errorf("--lang is required. Supported: %s", strings.Join(goapi.SupportedLanguages(), ", "))
}
// 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{
SpecPath: specFile,
OutputDir: output,
PackageName: packageName,
}
if !gen.Available() {
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
return fmt.Errorf("openapi-generator-cli not installed")
}
// Generate for each language.
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l == "" {
continue
}
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
if err := gen.Generate(context.Background(), l); err != nil {
return fmt.Errorf("generate %s: %w", l, err)
}
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
}
return nil
})
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to existing OpenAPI spec (generates from MCP tools if not provided)")
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
parent.AddCommand(cmd)
}

54
cmd/api/cmd_spec.go Normal file
View file

@ -0,0 +1,54 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"fmt"
"os"
"forge.lthn.ai/core/cli/pkg/cli"
goapi "forge.lthn.ai/core/api"
)
func addSpecCommand(parent *cli.Command) {
var (
output string
format string
title string
version string
)
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
// Build spec from registered route groups.
// Additional groups can be added here as the platform grows.
builder := &goapi.SpecBuilder{
Title: title,
Description: "Lethean Core API",
Version: version,
}
// Start with the default tool bridge — future versions will
// auto-populate from the MCP tool registry once the bridge
// integration lands in the local go-ai module.
bridge := goapi.NewToolBridge("/tools")
groups := []goapi.RouteGroup{bridge}
if output != "" {
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
return nil
}
return goapi.ExportSpec(os.Stdout, format, builder, groups)
})
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec")
cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec")
parent.AddCommand(cmd)
}

101
cmd/api/cmd_test.go Normal file
View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bytes"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
)
func TestAPISpecCmd_Good_CommandStructure(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
specCmd, _, err := apiCmd.Find([]string{"spec"})
if err != nil {
t.Fatalf("spec subcommand not found: %v", err)
}
if specCmd.Use != "spec" {
t.Fatalf("expected Use=spec, got %s", specCmd.Use)
}
}
func TestAPISpecCmd_Good_JSON(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
specCmd, _, err := apiCmd.Find([]string{"spec"})
if err != nil {
t.Fatalf("spec subcommand not found: %v", err)
}
// Verify flags exist
if specCmd.Flag("format") == nil {
t.Fatal("expected --format flag on spec command")
}
if specCmd.Flag("output") == nil {
t.Fatal("expected --output flag on spec command")
}
if specCmd.Flag("title") == nil {
t.Fatal("expected --title flag on spec command")
}
if specCmd.Flag("version") == nil {
t.Fatal("expected --version flag on spec command")
}
}
func TestAPISDKCmd_Bad_NoLang(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
root.SetArgs([]string{"api", "sdk"})
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
err := root.Execute()
if err == nil {
t.Fatal("expected error when --lang not provided")
}
}
func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
sdkCmd, _, err := apiCmd.Find([]string{"sdk"})
if err != nil {
t.Fatalf("sdk subcommand not found: %v", err)
}
// Verify flags exist
if sdkCmd.Flag("lang") == nil {
t.Fatal("expected --lang flag on sdk command")
}
if sdkCmd.Flag("output") == nil {
t.Fatal("expected --output flag on sdk command")
}
if sdkCmd.Flag("spec") == nil {
t.Fatal("expected --spec flag on sdk command")
}
if sdkCmd.Flag("package") == nil {
t.Fatal("expected --package flag on sdk command")
}
}

101
codegen.go Normal file
View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"context"
"fmt"
"iter"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
)
// Supported SDK target languages.
var supportedLanguages = map[string]string{
"go": "go",
"typescript-fetch": "typescript-fetch",
"typescript-axios": "typescript-axios",
"python": "python",
"java": "java",
"csharp": "csharp-netcore",
"ruby": "ruby",
"swift": "swift5",
"kotlin": "kotlin",
"rust": "rust",
"php": "php",
}
// SDKGenerator wraps openapi-generator-cli for SDK generation.
type SDKGenerator struct {
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
SpecPath string
// OutputDir is the base directory for generated SDK output.
OutputDir string
// PackageName is the name used for the generated package/module.
PackageName string
}
// Generate creates an SDK for the given language using openapi-generator-cli.
// The language must be one of the supported languages returned by SupportedLanguages().
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
generator, ok := supportedLanguages[language]
if !ok {
return fmt.Errorf("unsupported language %q: supported languages are %v", language, SupportedLanguages())
}
if _, err := os.Stat(g.SpecPath); os.IsNotExist(err) {
return fmt.Errorf("spec file not found: %s", g.SpecPath)
}
outputDir := filepath.Join(g.OutputDir, language)
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
args := g.buildArgs(generator, outputDir)
cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("openapi-generator-cli failed for %s: %w", language, err)
}
return nil
}
// buildArgs constructs the openapi-generator-cli command arguments.
func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
args := []string{
"generate",
"-i", g.SpecPath,
"-g", generator,
"-o", outputDir,
}
if g.PackageName != "" {
args = append(args, "--additional-properties", "packageName="+g.PackageName)
}
return args
}
// Available checks if openapi-generator-cli is installed and accessible.
func (g *SDKGenerator) Available() bool {
_, err := exec.LookPath("openapi-generator-cli")
return err == nil
}
// SupportedLanguages returns the list of supported SDK target languages
// in sorted order for deterministic output.
func SupportedLanguages() []string {
return slices.Sorted(maps.Keys(supportedLanguages))
}
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
func SupportedLanguagesIter() iter.Seq[string] {
return slices.Values(SupportedLanguages())
}

94
codegen_test.go Normal file
View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
api "forge.lthn.ai/core/api"
)
// ── SDKGenerator tests ─────────────────────────────────────────────────────
func TestSDKGenerator_Good_SupportedLanguages(t *testing.T) {
langs := api.SupportedLanguages()
if len(langs) == 0 {
t.Fatal("expected at least one supported language")
}
expected := []string{"go", "typescript-fetch", "python", "java", "csharp"}
for _, lang := range expected {
if !slices.Contains(langs, lang) {
t.Errorf("expected %q in supported languages, got %v", lang, langs)
}
}
}
func TestSDKGenerator_Bad_UnsupportedLanguage(t *testing.T) {
gen := &api.SDKGenerator{
SpecPath: "spec.json",
OutputDir: t.TempDir(),
}
err := gen.Generate(context.Background(), "brainfuck")
if err == nil {
t.Fatal("expected error for unsupported language, got nil")
}
if !strings.Contains(err.Error(), "unsupported language") {
t.Fatalf("expected error to contain 'unsupported language', got: %v", err)
}
}
func TestSDKGenerator_Bad_MissingSpec(t *testing.T) {
gen := &api.SDKGenerator{
SpecPath: filepath.Join(t.TempDir(), "nonexistent.json"),
OutputDir: t.TempDir(),
}
err := gen.Generate(context.Background(), "go")
if err == nil {
t.Fatal("expected error for missing spec file, got nil")
}
if !strings.Contains(err.Error(), "spec file not found") {
t.Fatalf("expected error to contain 'spec file not found', got: %v", err)
}
}
func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
// Write a minimal spec file so we pass the file-exists check.
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,
}
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
// but the output directory should have been created before that.
_ = gen.Generate(context.Background(), "go")
expected := filepath.Join(outputDir, "go")
info, err := os.Stat(expected)
if err != nil {
t.Fatalf("expected output directory %s to exist, got error: %v", expected, err)
}
if !info.IsDir() {
t.Fatalf("expected %s to be a directory", expected)
}
}
func TestSDKGenerator_Good_Available(t *testing.T) {
gen := &api.SDKGenerator{}
// Just verify it returns a bool and does not panic.
_ = gen.Available()
}

39
composer.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "lthn/api",
"description": "REST API module — Laravel API layer + standalone Go binary",
"keywords": ["api", "rest", "laravel", "openapi"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"lthn/php": "*",
"symfony/yaml": "^7.0"
},
"autoload": {
"psr-4": {
"Core\\Api\\": "src/php/src/Api/",
"Core\\Front\\Api\\": "src/php/src/Front/Api/",
"Core\\Website\\Api\\": "src/php/src/Website/Api/"
}
},
"autoload-dev": {
"psr-4": {
"Core\\Api\\Tests\\": "src/php/tests/"
}
},
"extra": {
"laravel": {
"providers": [
"Core\\Front\\Api\\Boot"
]
}
},
"config": {
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true,
"replace": {
"core/php-api": "self.version",
"lthn/php-api": "self.version"
}
}

617
docs/architecture.md Normal file
View file

@ -0,0 +1,617 @@
---
title: Architecture
description: Internals of the go-api REST framework -- Engine, RouteGroup, middleware composition, response envelope, authentication, real-time transports, OpenAPI generation, and SDK codegen.
---
<!-- SPDX-License-Identifier: EUPL-1.2 -->
# Architecture
This document explains how go-api works internally. It covers every major subsystem, the key
types, and the data flow from incoming HTTP request to outgoing JSON response.
---
## 1. Engine
### 1.1 The Engine struct
`Engine` is the central container. It holds the listen address, the ordered list of registered
route groups, the middleware chain, and all optional integrations:
```go
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
}
```
All fields are private. Configuration happens exclusively through `Option` functions passed
to `New()`.
### 1.2 Construction
`New()` applies functional options and returns a configured engine. The default listen address
is `:8080`. No middleware is added automatically beyond Gin's built-in panic recovery; every
feature requires an explicit `With*()` option:
```go
engine, err := api.New(
api.WithAddr(":9000"),
api.WithBearerAuth("secret"),
api.WithCORS("*"),
api.WithRequestID(),
api.WithSlog(nil),
api.WithSwagger("My API", "Description", "1.0.0"),
)
```
After construction, call `engine.Register(group)` to add route groups, then either
`engine.Serve(ctx)` to start an HTTP server or `engine.Handler()` to obtain an `http.Handler`
for use with `httptest` or an external server.
### 1.3 Build sequence
`Engine.build()` is called internally by `Handler()` and `Serve()`. It assembles a fresh
`*gin.Engine` each time. The order is fixed:
1. `gin.Recovery()` -- panic recovery (always first).
2. User middleware in registration order -- all `With*()` options that append to `e.middlewares`.
3. Built-in `GET /health` endpoint -- always present, returns `{"success":true,"data":"healthy"}`.
4. Route groups -- each mounted at its `BasePath()`.
5. WebSocket handler at `GET /ws` -- when `WithWSHandler()` was called.
6. SSE broker at `GET /events` -- when `WithSSE()` was called.
7. GraphQL endpoint -- when `WithGraphQL()` was called.
8. Swagger UI at `GET /swagger/*any` -- when `WithSwagger()` was called.
9. pprof endpoints at `GET /debug/pprof/*` -- when `WithPprof()` was called.
10. expvar endpoint at `GET /debug/vars` -- when `WithExpvar()` was called.
### 1.4 Graceful shutdown
`Serve()` starts an `http.Server` in a goroutine and blocks on `ctx.Done()`. When the context
is cancelled, a 10-second shutdown deadline is applied. In-flight requests complete or time out
before the server exits. Any listen error that occurred before shutdown is returned to the
caller.
### 1.5 Iterators
`Engine` provides iterator methods following Go 1.23+ conventions:
- `GroupsIter()` returns `iter.Seq[RouteGroup]` over all registered groups.
- `ChannelsIter()` returns `iter.Seq[string]` over WebSocket channel names from groups that
implement `StreamGroup`.
---
## 2. RouteGroup, StreamGroup, and DescribableGroup
Three interfaces form the extension point model:
```go
// RouteGroup is the minimum interface. All subsystems implement this.
type RouteGroup interface {
Name() string
BasePath() string
RegisterRoutes(rg *gin.RouterGroup)
}
// StreamGroup is optionally implemented by groups that publish WebSocket channels.
type StreamGroup interface {
Channels() []string
}
// DescribableGroup extends RouteGroup with OpenAPI metadata.
// Groups implementing this have their endpoints included in the generated spec.
type DescribableGroup interface {
RouteGroup
Describe() []RouteDescription
}
```
`RouteDescription` carries the HTTP method, path (relative to `BasePath()`), summary,
description, tags, and JSON Schema maps for the request body and response data:
```go
type RouteDescription struct {
Method string
Path string
Summary string
Description string
Tags []string
RequestBody map[string]any
Response map[string]any
}
```
`Engine.Channels()` iterates all registered groups and collects channel names from those that
implement `StreamGroup`. This list is used when initialising a WebSocket hub.
---
## 3. Middleware Stack
All middleware options append to `Engine.middlewares` in the order they are passed to `New()`.
They execute after `gin.Recovery()` but before any route handler. The `Option` type is simply
`func(*Engine)`.
### Complete option reference
| Option | Purpose | Key detail |
|--------|---------|-----------|
| `WithAddr(addr)` | Listen address | Default `:8080` |
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
| `WithGzip(level...)` | Gzip response compression | 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 |
| `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 |
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store |
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
| `WithSSE(broker)` | Server-Sent Events at `/events` | `?channel=` query parameter filtering |
| `WithLocation()` | Reverse proxy header detection | X-Forwarded-Proto / X-Forwarded-Host |
| `WithI18n(cfg...)` | Accept-Language locale detection | BCP 47 matching via `golang.org/x/text/language` |
| `WithTracing(name, opts...)` | OpenTelemetry distributed tracing | otelgin + W3C `traceparent` header propagation |
| `WithGraphQL(schema, opts...)` | GraphQL endpoint | gqlgen `ExecutableSchema`; optional playground UI |
### Bearer authentication flow
`bearerAuthMiddleware` validates the `Authorization: Bearer <token>` header. Requests to paths
in the skip list (`/health`, `/swagger`) pass through without authentication. Missing or
invalid tokens produce a `401 Unauthorised` response using the standard error envelope.
### Request ID flow
`requestIDMiddleware` checks for an incoming `X-Request-ID` header. If present, the value is
preserved. Otherwise, a cryptographically random 16-byte hex string is generated. The ID is
stored in the Gin context under the key `"request_id"` and set as an `X-Request-ID` response
header.
---
## 4. Response Envelope
All API responses use a single generic envelope:
```go
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
```
Supporting types:
```go
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
type Meta struct {
RequestID string `json:"request_id,omitempty"`
Duration string `json:"duration,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
}
```
### Constructor helpers
| Helper | Produces |
|--------|----------|
| `OK(data)` | `{"success":true,"data":...}` |
| `Fail(code, message)` | `{"success":false,"error":{"code":"...","message":"..."}}` |
| `FailWithDetails(code, message, details)` | Same as `Fail` with an additional `details` field |
| `Paginated(data, page, perPage, total)` | `{"success":true,"data":...,"meta":{"page":...,"per_page":...,"total":...}}` |
All handlers should use these helpers rather than constructing `Response[T]` manually. This
guarantees a consistent envelope across every route group.
---
## 5. Authentik Integration
The `WithAuthentik()` option installs a permissive identity middleware that runs on every
non-public request. It has two extraction paths:
### Path 1 -- Forward-auth headers (TrustedProxy: true)
When a reverse proxy (e.g. Traefik) is configured with Authentik forward-auth, it injects
headers: `X-authentik-username`, `X-authentik-email`, `X-authentik-name`, `X-authentik-uid`,
`X-authentik-groups` (pipe-separated), `X-authentik-entitlements` (pipe-separated), and
`X-authentik-jwt`. The middleware reads these and populates an `AuthentikUser` in the Gin
context.
### Path 2 -- OIDC JWT validation
For direct API clients that present a `Bearer` token, the middleware validates the JWT against
the configured OIDC issuer and client ID. Providers are cached by issuer URL to avoid repeated
discovery requests.
### Fail-open behaviour
In both paths, if extraction fails the request continues unauthenticated. The middleware never
rejects requests. Handlers check identity with:
```go
user := api.GetUser(c) // returns nil when unauthenticated
```
### Route guards
For protected routes, apply guards as Gin middleware on individual routes:
```go
rg.GET("/private", api.RequireAuth(), handler) // 401 if no user
rg.GET("/admin", api.RequireGroup("admins"), handler) // 403 if wrong group
```
`RequireAuth()` returns 401 when `GetUser(c)` is nil. `RequireGroup(group)` returns 401 when
no user is present, or 403 when the user lacks the specified group membership.
### AuthentikUser type
```go
type AuthentikUser struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
UID string `json:"uid"`
Groups []string `json:"groups,omitempty"`
Entitlements []string `json:"entitlements,omitempty"`
JWT string `json:"-"`
}
```
The `HasGroup(group string) bool` method provides a convenience check for group membership.
### Configuration
```go
type AuthentikConfig struct {
Issuer string // OIDC issuer URL
ClientID string // OAuth2 client identifier
TrustedProxy bool // Whether to read X-authentik-* headers
PublicPaths []string // Additional paths exempt from header extraction
}
```
`/health` and `/swagger` are always public. Additional paths may be specified via
`PublicPaths`.
---
## 6. WebSocket and Server-Sent Events
### WebSocket
`WithWSHandler(h)` mounts any `http.Handler` at `GET /ws`. The handler is responsible for
upgrading the connection. The intended pairing is a WebSocket hub (e.g. from go-ws):
```go
hub := ws.NewHub()
go hub.Run(ctx)
engine, _ := api.New(api.WithWSHandler(hub.Handler()))
```
Groups implementing `StreamGroup` declare channel names, which `Engine.Channels()` aggregates
into a single slice.
### Server-Sent Events
`SSEBroker` manages persistent SSE connections at `GET /events`. Clients optionally subscribe
to a named channel via the `?channel=<name>` query parameter. Clients without a channel
parameter receive events on all channels.
```go
broker := api.NewSSEBroker()
engine, _ := api.New(api.WithSSE(broker))
// Publish from anywhere:
broker.Publish("deployments", "deploy.started", payload)
```
Key implementation details:
- Each client has a 64-event buffered channel. Overflow events are dropped without blocking
the publisher.
- `SSEBroker.ClientCount()` returns the number of currently connected clients.
- `SSEBroker.Drain()` signals all clients to disconnect, useful during graceful shutdown.
- The response is streamed with `Content-Type: text/event-stream`, `Cache-Control: no-cache`,
`Connection: keep-alive`, and `X-Accel-Buffering: no` headers.
- Data payloads are JSON-encoded before being written as SSE `data:` fields.
---
## 7. GraphQL
`WithGraphQL()` mounts a gqlgen `ExecutableSchema` at `/graphql` (or a custom path via
`WithGraphQLPath()`). An optional `WithPlayground()` adds the interactive GraphQL Playground
at `{path}/playground`.
```go
engine, _ := api.New(
api.WithGraphQL(schema,
api.WithPlayground(),
api.WithGraphQLPath("/gql"),
),
)
```
The endpoint accepts all HTTP methods (POST for queries and mutations, GET for playground
redirects and introspection). The GraphQL handler is created via gqlgen's
`handler.NewDefaultServer()`.
---
## 8. Response Caching
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests:
- Only successful 2xx responses are cached.
- Non-GET methods pass through uncached.
- Cached responses are served with an `X-Cache: HIT` header.
- Expired entries are evicted lazily on the next access for the same key.
- The cache is not shared across `Engine` instances.
- There is no size limit on the cache.
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
capture the response body and status code for storage.
---
## 9. Brotli Compression
`WithBrotli(level...)` adds Brotli response compression. The middleware checks the
`Accept-Encoding` header for `br` support before compressing.
Key implementation details:
- A `sync.Pool` of `brotli.Writer` instances is used to avoid allocation per request.
- Error responses (4xx and above) bypass compression and are sent uncompressed.
- Three compression level constants are exported: `BrotliBestSpeed`, `BrotliBestCompression`,
and `BrotliDefaultCompression`.
---
## 10. Internationalisation
`WithI18n(cfg)` parses the `Accept-Language` header on every request and stores the resolved
BCP 47 locale tag in the Gin context. The `golang.org/x/text/language` matcher handles
quality-weighted negotiation and script/region subtag matching.
```go
engine, _ := api.New(
api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr", "de"},
Messages: map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
},
}),
)
```
Handlers retrieve the locale and optional localised messages:
```go
locale := api.GetLocale(c) // e.g. "en", "fr"
msg, ok := api.GetMessage(c, "greeting") // from configured Messages map
```
The built-in message map is a lightweight bridge. The `go-i18n` grammar engine is the intended
replacement for production-grade localisation.
---
## 11. OpenTelemetry Tracing
`WithTracing(serviceName)` adds otelgin middleware that creates a span for each request, tagged
with HTTP method, route template, and response status code. Trace context is propagated via the
W3C `traceparent` header.
`NewTracerProvider(exporter)` is a convenience helper for tests and simple deployments that
constructs a synchronous `TracerProvider` and installs it globally:
```go
tp := api.NewTracerProvider(exporter)
defer tp.Shutdown(ctx)
engine, _ := api.New(api.WithTracing("my-service"))
```
Production deployments should build a batching provider with appropriate resource attributes
and span processors.
---
## 12. OpenAPI Specification Generation
### SpecBuilder
`SpecBuilder` generates an OpenAPI 3.1 JSON document from registered route groups:
```go
builder := &api.SpecBuilder{
Title: "My API",
Description: "Service description",
Version: "1.0.0",
}
data, err := builder.Build(engine.Groups())
```
The built document includes:
- The `GET /health` endpoint under the `system` tag.
- One path entry per `RouteDescription` returned by `DescribableGroup.Describe()`.
- `#/components/schemas/Error` and `#/components/schemas/Meta` shared schemas.
- All response bodies wrapped in the `Response[T]` envelope schema.
- Tags derived from every registered group's `Name()`.
Groups that implement `RouteGroup` but not `DescribableGroup` contribute a tag but no paths.
### Export
Two convenience functions write the spec to an `io.Writer` or directly to a file:
```go
// Write JSON or YAML to a writer:
api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups())
// Write to a file (parent directories created automatically):
api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())
```
### Swagger UI
When `WithSwagger()` is active, the spec is built lazily on first access by a `swaggerSpec`
wrapper that satisfies the `swag.Spec` interface. It is registered in the global `swag` registry
with a unique sequence-based instance name (via `atomic.Uint64`), so multiple `Engine` instances
in the same process do not collide.
---
## 13. ToolBridge
`ToolBridge` converts tool descriptors into REST endpoints and OpenAPI paths. It implements both
`RouteGroup` and `DescribableGroup`. This is the primary mechanism for exposing MCP tool
descriptors as a REST API.
```go
bridge := api.NewToolBridge("/v1/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file",
Group: "files",
InputSchema: map[string]any{"type": "object", "properties": ...},
}, fileReadHandler)
engine.Register(bridge)
```
Each registered tool becomes a `POST /v1/tools/{tool_name}` endpoint. The bridge provides:
- `Tools()` / `ToolsIter()` -- enumerate registered tool descriptors.
- `Describe()` / `DescribeIter()` -- generate `RouteDescription` entries for OpenAPI.
`ToolDescriptor` carries:
```go
type ToolDescriptor struct {
Name string // Tool name (becomes POST path segment)
Description string // Human-readable description
Group string // OpenAPI tag group
InputSchema map[string]any // JSON Schema for request body
OutputSchema map[string]any // JSON Schema for response data (optional)
}
```
---
## 14. SDK Codegen
`SDKGenerator` wraps `openapi-generator-cli` to generate client SDKs from an exported OpenAPI
spec:
```go
gen := &api.SDKGenerator{
SpecPath: "./api/openapi.yaml",
OutputDir: "./sdk",
PackageName: "myapi",
}
if gen.Available() {
_ = gen.Generate(ctx, "typescript-fetch")
_ = gen.Generate(ctx, "python")
}
```
Supported target languages (11 total): `csharp`, `go`, `java`, `kotlin`, `php`, `python`,
`ruby`, `rust`, `swift`, `typescript-axios`, `typescript-fetch`.
- `SupportedLanguages()` returns the full list in sorted order.
- `SupportedLanguagesIter()` returns an `iter.Seq[string]` over the same list.
- `SDKGenerator.Available()` checks whether `openapi-generator-cli` is on `PATH`.
---
## 15. CLI Subcommands
The `cmd/api/` package registers two CLI subcommands under the `core api` namespace:
### `core api spec`
Generates an OpenAPI 3.1 specification from registered route groups.
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--output` | `-o` | (stdout) | Write spec to file |
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
| `--title` | `-t` | `Lethean Core API` | API title |
| `--version` | `-V` | `1.0.0` | API version |
### `core api sdk`
Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`.
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--lang` | `-l` | (required) | Target language(s), comma-separated |
| `--output` | `-o` | `./sdk` | Output directory |
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
| `--package` | `-p` | `lethean` | Package name for generated SDK |
---
## 16. Data Flow Summary
```
HTTP Request
|
v
gin.Recovery() -- panic recovery
|
v
User middleware chain -- WithBearerAuth, WithCORS, WithRequestID, WithAuthentik, etc.
| (in registration order)
v
Route matching -- /health (built-in) or BasePath() + route from RouteGroup
|
v
Handler function -- uses api.OK(), api.Fail(), api.Paginated()
|
v
Response[T] envelope -- {"success": bool, "data": T, "error": Error, "meta": Meta}
|
v
HTTP Response
```
Real-time transports (WebSocket at `/ws`, SSE at `/events`) and development endpoints
(Swagger at `/swagger/`, pprof at `/debug/pprof/`, expvar at `/debug/vars`) are mounted
alongside the route groups during the build phase.

451
docs/development.md Normal file
View file

@ -0,0 +1,451 @@
---
title: Development Guide
description: How to build, test, and contribute to the go-api REST framework -- prerequisites, test patterns, extension guides, and coding standards.
---
<!-- SPDX-License-Identifier: EUPL-1.2 -->
# Development Guide
This guide covers everything needed to build, test, extend, and contribute to go-api.
**Module path:** `forge.lthn.ai/core/go-api`
**Licence:** EUPL-1.2
**Language:** Go 1.26
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Building](#building)
3. [Testing](#testing)
4. [Test Patterns](#test-patterns)
5. [Adding a New With*() Option](#adding-a-new-with-option)
6. [Adding a RouteGroup](#adding-a-routegroup)
7. [Adding a DescribableGroup](#adding-a-describablegroup)
8. [Coding Standards](#coding-standards)
9. [Commit Guidelines](#commit-guidelines)
---
## Prerequisites
### Go toolchain
Go 1.26 or later is required. Verify the installed version:
```bash
go version
```
### Minimal dependencies
go-api has no sibling `forge.lthn.ai/core/*` dependencies at the library level (the `cmd/api/`
subcommands import `core/cli`, but the main package compiles independently). There are no
`replace` directives. Cloning go-api alone is sufficient to build and test the library.
If working within the Go workspace at `~/Code/go.work`, the workspace `use` directive handles
local module resolution automatically.
---
## Building
go-api is a library module with no `main` package. Build all packages to verify that everything
compiles cleanly:
```bash
go build ./...
```
Vet for suspicious constructs:
```bash
go vet ./...
```
Neither command produces a binary. If you need a runnable server for manual testing, create a
temporary `main.go` that imports go-api and calls `engine.Serve()`.
---
## Testing
### Run all tests
```bash
go test ./...
```
### Run a single test by name
```bash
go test -run TestName ./...
```
The `-run` flag accepts a regular expression:
```bash
go test -run TestToolBridge ./...
go test -run TestSpecBuilder_Good ./...
go test -run "Test.*_Bad" ./...
```
### Verbose output
```bash
go test -v ./...
```
### Race detector
Always run with `-race` before opening a pull request. The middleware layer uses concurrency
(SSE broker, cache store, Brotli writer pool), and the race detector catches data races
reliably:
```bash
go test -race ./...
```
Note: The repository includes `race_test.go` and `norace_test.go` build-tag files that control
which tests run under the race detector.
### Live Authentik integration tests
`authentik_integration_test.go` contains tests that require a live Authentik instance. These
are skipped automatically unless the `AUTHENTIK_ISSUER` and `AUTHENTIK_CLIENT_ID` environment
variables are set. They do not run in standard CI.
To run them locally:
```bash
AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/ \
AUTHENTIK_CLIENT_ID=my-client-id \
go test -run TestAuthentik_Integration ./...
```
---
## Test Patterns
### Naming convention
All test functions follow the `_Good`, `_Bad`, `_Ugly` suffix pattern:
| Suffix | Purpose |
|---------|---------|
| `_Good` | Happy path -- the input is valid and the operation succeeds |
| `_Bad` | Expected error conditions -- invalid input, missing config, wrong state |
| `_Ugly` | Panics and extreme edge cases -- nil receivers, resource exhaustion, concurrent access |
Examples from the codebase:
```
TestNew_Good
TestNew_Good_WithAddr
TestHandler_Bad_NotFound
TestSDKGenerator_Bad_UnsupportedLanguage
TestSpecBuilder_Good_SingleDescribableGroup
```
### Engine test helpers
Tests that need a running HTTP server use `httptest.NewRecorder()` or `httptest.NewServer()`.
Build the engine handler directly rather than calling `Serve()`:
```go
gin.SetMode(gin.TestMode)
engine, _ := api.New(api.WithBearerAuth("tok"))
engine.Register(&myGroup{})
handler := engine.Handler()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
```
### SSE tests
SSE handler tests open a real HTTP connection with `httptest.NewServer()` and read the
`text/event-stream` response body line by line. Publish from a goroutine and use a deadline
to avoid hanging indefinitely:
```go
srv := httptest.NewServer(engine.Handler())
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
resp, _ := http.DefaultClient.Do(req)
// read lines from resp.Body
```
### Cache tests
Cache tests must use `httptest.NewServer()` rather than a recorder because the cache middleware
needs a proper response cycle to capture and replay responses. Verify the `X-Cache: HIT` header
on the second request to the same URL.
### Authentik tests
Authentik middleware tests use raw `httptest.NewRequest()` with `X-authentik-*` headers set
directly. No live Authentik instance is required for unit tests -- the permissive middleware is
exercised entirely through header injection and context assertions.
---
## Adding a New With*() Option
`With*()` options are the primary extension point. All options follow an identical pattern.
### Step 1: Add the option function
Open `options.go` and add a new exported function that returns an `Option`:
```go
// WithRateLimit adds request rate limiting middleware.
// Requests exceeding limit per second per IP are rejected with 429 Too Many Requests.
func WithRateLimit(limit int) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, rateLimitMiddleware(limit))
}
}
```
If the option stores state on `Engine` (like `swaggerEnabled` or `sseBroker`), add the
corresponding field to the `Engine` struct in `api.go` and reference it in `build()`.
### Step 2: Implement the middleware
If the option wraps a `gin-contrib` package, follow the existing pattern in `options.go`
(inline). For options with non-trivial logic, create a dedicated file (e.g. `ratelimit.go`).
Every new source file must begin with the EUPL-1.2 SPDX identifier:
```go
// SPDX-License-Identifier: EUPL-1.2
package api
import "github.com/gin-gonic/gin"
func rateLimitMiddleware(limit int) gin.HandlerFunc {
// implementation
}
```
### Step 3: Add the dependency to go.mod
If the option relies on a new external package:
```bash
go get github.com/example/ratelimit
go mod tidy
```
### Step 4: Write tests
Create a test file (e.g. `ratelimit_test.go`) following the `_Good`/`_Bad`/`_Ugly` naming
convention. Test with `httptest` rather than calling `Serve()`.
### Step 5: Update the build path
If the option adds a new built-in HTTP endpoint (like WebSocket at `/ws` or SSE at `/events`),
add it to the `build()` method in `api.go` after the GraphQL block but before Swagger.
---
## Adding a RouteGroup
`RouteGroup` is the standard way for subsystem packages to contribute REST endpoints.
### Minimum implementation
```go
// SPDX-License-Identifier: EUPL-1.2
package mypackage
import (
"net/http"
api "forge.lthn.ai/core/go-api"
"github.com/gin-gonic/gin"
)
type Routes struct {
service *Service
}
func NewRoutes(svc *Service) *Routes {
return &Routes{service: svc}
}
func (r *Routes) Name() string { return "mypackage" }
func (r *Routes) BasePath() string { return "/v1/mypackage" }
func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/items", r.listItems)
rg.POST("/items", r.createItem)
}
func (r *Routes) listItems(c *gin.Context) {
items, err := r.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("internal", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(items))
}
```
Register with the engine:
```go
engine.Register(mypackage.NewRoutes(svc))
```
### Adding StreamGroup
If the group publishes WebSocket channels, implement `StreamGroup` as well:
```go
func (r *Routes) Channels() []string {
return []string{"mypackage.items.updated"}
}
```
---
## Adding a DescribableGroup
`DescribableGroup` extends `RouteGroup` with OpenAPI metadata. Implementing it ensures the
group's endpoints appear in the generated spec and Swagger UI.
Add a `Describe()` method that returns a slice of `RouteDescription`:
```go
func (r *Routes) Describe() []api.RouteDescription {
return []api.RouteDescription{
{
Method: "GET",
Path: "/items",
Summary: "List items",
Tags: []string{"mypackage"},
Response: map[string]any{
"type": "array",
"items": map[string]any{"type": "object"},
},
},
{
Method: "POST",
Path: "/items",
Summary: "Create an item",
Tags: []string{"mypackage"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
"required": []string{"name"},
},
Response: map[string]any{"type": "object"},
},
}
}
```
Paths in `RouteDescription` are relative to `BasePath()`. The `SpecBuilder` concatenates them
when building the full OpenAPI path.
---
## Coding Standards
### Language
Use **UK English** in all comments, documentation, log messages, and user-facing strings:
`colour`, `organisation`, `centre`, `initialise`, `licence` (noun), `license` (verb),
`unauthorised`, `authorisation`.
### Licence header
Every new Go source file must carry the EUPL-1.2 SPDX identifier as the first line:
```go
// SPDX-License-Identifier: EUPL-1.2
package api
```
### Error handling
- Always return errors rather than panicking.
- Wrap errors with context: `fmt.Errorf("component.Operation: what failed: %w", err)`.
- Do not discard errors with `_` unless the operation is genuinely fire-and-forget and the
reason is documented with a comment.
- Log errors at the point of handling, not at the point of wrapping.
### Response envelope
All HTTP handlers must use the `api.OK()`, `api.Fail()`, `api.FailWithDetails()`, or
`api.Paginated()` helpers rather than constructing `Response[T]` directly. This ensures the
envelope structure is consistent across all route groups.
### Test naming
- Function names: `Test{Type}_{Suffix}_{Description}` where `{Suffix}` is `Good`, `Bad`,
or `Ugly`.
- Helper constructors: `newTest{Type}(t *testing.T, ...) *Type`.
- Always call `t.Helper()` at the top of every test helper function.
### Formatting
The codebase uses `gofmt` defaults. Run before committing:
```bash
gofmt -l -w .
```
### Middleware conventions
- Every `With*()` function must append to `e.middlewares`, not modify Gin routes directly.
Routes are only registered in `build()`.
- Options that require `Engine` struct fields (like `swaggerEnabled` or `sseBroker`) must be
readable by `build()`, not set inside a closure without a backing field.
- Middleware that exposes sensitive data (`WithPprof`, `WithExpvar`) must carry a `// WARNING:`
comment in the godoc directing users away from production exposure without authentication.
---
## Commit Guidelines
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): description
Body explaining what changed and why.
Co-Authored-By: Virgil <virgil@lethean.io>
```
Types in use across the repository: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`.
Example:
```
feat(api): add WithRateLimit per-IP rate limiting middleware
Adds configurable per-IP rate limiting using a token-bucket algorithm.
Requests exceeding the limit per second are rejected with 429 Too Many
Requests and a standard Fail() error envelope.
Co-Authored-By: Virgil <virgil@lethean.io>
```

219
docs/history.md Normal file
View file

@ -0,0 +1,219 @@
<!-- SPDX-License-Identifier: EUPL-1.2 -->
# go-api — Project History and Known Limitations
Module: `forge.lthn.ai/core/go-api`
---
## Origins
`go-api` was created as the dedicated HTTP framework for the Lethean Go ecosystem. The motivation
was to give every Go package in the stack a consistent way to expose REST endpoints without each
package taking its own opinion on routing, middleware, response formatting, or OpenAPI generation.
It was scaffolded independently from the start — it was never extracted from a monolith — and has
no `forge.lthn.ai/core/*` dependencies. This keeps it at the bottom of the import graph: every
other package can import go-api, but go-api imports nothing from the ecosystem.
---
## Development Phases
### Phase 1 — Core Engine (36 tests)
Commits `889391a` through `22f8a69`
The initial phase established the foundational abstractions that all subsequent work builds on.
**Scaffold** (`889391a`):
Module path `forge.lthn.ai/core/go-api` created. `go.mod` initialised with Gin as the only
direct dependency.
**Response envelope** (`7835837`):
`Response[T]`, `Error`, and `Meta` types defined. `OK()`, `Fail()`, and `Paginated()` helpers
added. The generic envelope was established from the start rather than retrofitted.
**RouteGroup interface** (`6f5fb69`):
`RouteGroup` (Name, BasePath, RegisterRoutes) and `StreamGroup` (Channels) interfaces defined
in `group.go`. The interface-driven extension model was the core design decision of Phase 1.
**Engine** (`db75c88`):
`Engine` struct added with `New()`, `Register()`, `Handler()`, `Serve()`, and graceful shutdown.
Default listen address `:8080`. Built-in `GET /health` endpoint. Panic recovery via `gin.Recovery()`
always applied first.
**Bearer auth, request ID, CORS** (`d21734d`):
First three middleware options: `WithBearerAuth()`, `WithRequestID()`, `WithCORS()`.
The functional `Option` type and the `With*()` pattern were established here.
**Swagger UI** (`22f8a69`):
`WithSwagger()` added. The initial implementation served a static Swagger UI backed by a placeholder
spec; this was later replaced in Phase 3.
**WebSocket** (`22f8a69`):
`WithWSHandler()` added, mounting any `http.Handler` at `GET /ws`. `Engine.Channels()` added to
aggregate channel names from `StreamGroup` implementations.
By the end of Phase 1, the module had 36 tests covering the engine lifecycle, health endpoint,
response helpers, bearer auth, request ID propagation, CORS, and WebSocket mounting.
---
### Phase 2 — 21 Middleware Options (143 tests)
Commits `d760e77` through `8ba1716`
Phase 2 expanded the middleware library in four waves, reaching 21 `With*()` options total.
**Wave 1 — Security and Identity** (`d760e77` through `8f3e496`):
The Authentik integration was the most significant addition of this wave.
- `WithAuthentik()` — permissive forward-auth middleware. Reads `X-authentik-*` headers when
`TrustedProxy: true`, validates JWTs via OIDC discovery when `Issuer` and `ClientID` are set.
Fail-open: unauthenticated requests are never rejected by this middleware alone.
- `RequireAuth()`, `RequireGroup()` — explicit guards for protected routes, returning 401 and 403
respectively via the standard `Fail()` envelope.
- `GetUser()` — context accessor for the current `AuthentikUser`.
- `AuthentikUser` — carries Username, Email, Name, UID, Groups, Entitlements, and JWT. `HasGroup()`
convenience method added.
- Live integration tests added in `authentik_integration_test.go`, guarded by environment variables.
- `WithSecure()` — HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, strict referrer
policy. SSL redirect deliberately omitted to work correctly behind a TLS-terminating proxy.
**Wave 2 — Compression and Logging** (`6521b90` through `6bb7195`):
- `WithTimeout(d)` — per-request deadline via gin-contrib/timeout. Returns 504 with the standard
Fail() envelope on expiry.
- `WithGzip(level...)` — gzip response compression; defaults to `gzip.DefaultCompression`.
- `WithBrotli(level...)` — Brotli compression via `andybalholm/brotli`. Custom `brotliHandler`
wrapping `brotli.HTTPCompressor`.
- `WithSlog(logger)` — structured request logging via gin-contrib/slog. Falls back to
`slog.Default()` when nil is passed.
- `WithStatic(prefix, root)` — static file serving via gin-contrib/static; directory listing
disabled.
**Wave 3 — Auth, Caching, Streaming** (`0ab962a` through `7b3f99e`):
- `WithCache(ttl)` — in-memory GET response cache. Custom `cacheWriter` intercepts the response
body without affecting the downstream handler. `X-Cache: HIT` on served cache entries.
- `WithSessions(name, secret)` — cookie-backed server sessions via gin-contrib/sessions.
- `WithAuthz(enforcer)` — Casbin policy-based authorisation via gin-contrib/authz. Subject from
HTTP Basic Auth.
- `WithHTTPSign(secrets, opts...)` — HTTP Signatures verification via gin-contrib/httpsign.
- `WithSSE(broker)` — Server-Sent Events at `GET /events`. `SSEBroker` added with `Publish()`,
channel filtering, 64-event per-client buffer, and `Drain()` for graceful shutdown.
**Wave 4 — Infrastructure and Protocol** (`a612d85` through `8ba1716`):
- `WithLocation()` — reverse proxy header detection via gin-contrib/location/v2.
- `WithI18n(cfg...)` — Accept-Language parsing and BCP 47 locale matching via
`golang.org/x/text/language`. `GetLocale()` and `GetMessage()` context accessors added.
- `WithGraphQL(schema, opts...)` — gqlgen `ExecutableSchema` mounting. `WithPlayground()` and
`WithGraphQLPath()` sub-options. Playground at `{path}/playground`.
- `WithPprof()` — Go runtime profiling at `/debug/pprof/`.
- `WithExpvar()` — expvar runtime metrics at `/debug/vars`.
- `WithTracing(name, opts...)` — OpenTelemetry distributed tracing via otelgin. `NewTracerProvider()`
convenience helper added. W3C `traceparent` propagation.
At the end of Phase 2, the module had 143 tests.
---
### Phase 3 — OpenAPI, ToolBridge, SDK Codegen (176 tests)
Commits `465bd60` through `1910aec`
Phase 3 upgraded the Swagger integration from a placeholder to a full runtime OpenAPI 3.1 pipeline
and added two new subsystems: `ToolBridge` and `SDKGenerator`.
**DescribableGroup interface** (`465bd60`):
`DescribableGroup` added to `group.go`, extending `RouteGroup` with `Describe() []RouteDescription`.
`RouteDescription` carries HTTP method, path, summary, description, tags, and JSON Schema maps
for request body and response data. This was the contract that `SpecBuilder` and `ToolBridge`
would both consume.
**ToolBridge** (`2b63c7b`):
`ToolBridge` added to `bridge.go`. Converts `ToolDescriptor` values into `POST /{tool_name}`
Gin routes and implements `DescribableGroup` so those routes appear in the OpenAPI spec. Designed
to bridge the MCP tool model (as used by go-ai) into the REST world. `Tools()` accessor added
for external enumeration.
**SpecBuilder** (`3e96f9b`):
`SpecBuilder` added to `openapi.go`. Generates a complete OpenAPI 3.1 JSON document from registered
`RouteGroup` and `DescribableGroup` values. Includes the built-in `GET /health` endpoint, shared
`Error` and `Meta` component schemas, and the `Response[T]` envelope schema wrapping every response
body. Tags are derived from all group names, not just describable ones.
**Spec export** (`e94283b`):
`ExportSpec()` and `ExportSpecToFile()` added to `export.go`. Supports `"json"` and `"yaml"`
output formats. YAML output is produced by unmarshalling the JSON then re-encoding with
`gopkg.in/yaml.v3` at 2-space indentation. Parent directories created automatically by
`ExportSpecToFile()`.
**Swagger refactor** (`303779f`):
`registerSwagger()` in `swagger.go` rewritten to use `SpecBuilder` rather than the previous
placeholder. A `swaggerSpec` wrapper satisfies the `swag.Spec` interface and builds the spec
lazily on first access via `sync.Once`. A `swaggerSeq` atomic counter assigns unique instance
names so multiple `Engine` instances in the same test binary do not collide in the global
`swag` registry.
**SDK codegen** (`a09a4e9`, `1910aec`):
`SDKGenerator` added to `codegen.go`. Wraps `openapi-generator-cli` to generate client SDKs
for 11 target languages. `SupportedLanguages()` returns the list in sorted order (the sort was
added in `1910aec` to ensure deterministic output in tests and documentation).
At the end of Phase 3, the module has 176 tests.
---
## Known Limitations
### 1. Cache has no size limit
`WithCache(ttl)` stores all successful GET responses in memory with no maximum entry count or
total size bound. For a server receiving requests to many distinct URLs, the cache will grow
without bound. A LRU eviction policy or a configurable maximum is the natural next step.
### 2. SDK codegen requires an external binary
`SDKGenerator.Generate()` shells out to `openapi-generator-cli`. This requires a JVM and the
openapi-generator JAR to be installed on the host. `Available()` checks whether the CLI is on
`PATH` but there is no programmatic fallback. Packaging `openapi-generator-cli` via a Docker
wrapper or replacing it with a pure-Go generator would remove this external dependency.
### 3. OpenAPI spec generation is build-time only
`SpecBuilder.Build()` generates the spec from `Describe()` return values, which are static at
the time of construction. Dynamic route generation (for example, routes registered after
`New()` returns) is not reflected in the spec. This matches the current design — all groups
must be registered before `Serve()` is called — but it would conflict with any future dynamic
route registration model.
### 4. i18n message map is a lightweight bridge only
`WithI18n()` accepts a `Messages map[string]map[string]string` for simple key-value lookups.
It does not support pluralisation, gender inflection, argument interpolation, or any of the
grammar features provided by `go-i18n`. Applications requiring production-grade localisation
should use `go-i18n` directly and use `GetLocale()` to pass the detected locale to it.
### 5. Authentik JWT validation performs OIDC discovery on first request
`getOIDCProvider()` performs an OIDC discovery request on first use and caches the resulting
`*oidc.Provider` by issuer URL. This is lazy — the first request to a non-public path will
incur a network round-trip to the issuer. A warm-up call during application startup would
eliminate this latency from the first real request.
### 6. ToolBridge has no input validation
`ToolBridge.Add()` accepts a `ToolDescriptor` with `InputSchema` and `OutputSchema` maps, but
these are used only for OpenAPI documentation. The registered `gin.HandlerFunc` is responsible
for its own input validation. There is no automatic binding or validation of incoming request
bodies against the declared JSON Schema.
### 7. SSEBroker.Drain() does not wait for clients to disconnect
`Drain()` closes all client event channels to signal disconnection but returns immediately
without waiting for client goroutines to exit. In a graceful shutdown sequence, there is a
brief window where client HTTP connections remain open. The engine's 10-second shutdown
deadline covers this window in practice, but there is no explicit coordination.

173
docs/index.md Normal file
View file

@ -0,0 +1,173 @@
---
title: go-api
description: Gin-based REST framework with OpenAPI generation, middleware composition, and SDK codegen for the Lethean Go ecosystem.
---
<!-- SPDX-License-Identifier: EUPL-1.2 -->
# go-api
**Module path:** `forge.lthn.ai/core/go-api`
**Language:** Go 1.26
**Licence:** EUPL-1.2
go-api is a REST framework built on top of [Gin](https://github.com/gin-gonic/gin). It provides
an `Engine` that subsystems plug into via the `RouteGroup` interface. Each ecosystem package
(go-ai, go-ml, go-rag, and others) registers its own route group, and go-api handles the HTTP
plumbing: middleware composition, response envelopes, WebSocket and SSE integration, GraphQL
hosting, Authentik identity, OpenAPI 3.1 specification generation, and client SDK codegen.
go-api is a library. It has no `main` package and produces no binary on its own. Callers
construct an `Engine`, register route groups, and call `Serve()`.
---
## Quick Start
```go
package main
import (
"context"
"os/signal"
"syscall"
api "forge.lthn.ai/core/go-api"
)
func main() {
engine, _ := api.New(
api.WithAddr(":8080"),
api.WithBearerAuth("my-secret-token"),
api.WithCORS("*"),
api.WithRequestID(),
api.WithSecure(),
api.WithSlog(nil),
api.WithSwagger("My API", "A service description", "1.0.0"),
)
engine.Register(myRoutes) // any RouteGroup implementation
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
_ = engine.Serve(ctx) // blocks until context is cancelled, then shuts down gracefully
}
```
The default listen address is `:8080`. A built-in `GET /health` endpoint is always present.
Every feature beyond panic recovery requires an explicit `With*()` option.
---
## Implementing a RouteGroup
Any type that satisfies the `RouteGroup` interface can register endpoints:
```go
type Routes struct{ service *mypackage.Service }
func (r *Routes) Name() string { return "mypackage" }
func (r *Routes) BasePath() string { return "/v1/mypackage" }
func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/items", r.listItems)
rg.POST("/items", r.createItem)
}
func (r *Routes) listItems(c *gin.Context) {
items, _ := r.service.List(c.Request.Context())
c.JSON(200, api.OK(items))
}
```
Register with the engine:
```go
engine.Register(&Routes{service: svc})
```
---
## Package Layout
| File | Purpose |
|------|---------|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
| `options.go` | All `With*()` option functions (25 options) |
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
| `authentik.go` | `AuthentikUser`, `AuthentikConfig`, `GetUser()`, `RequireAuth()`, `RequireGroup()` |
| `websocket.go` | `wrapWSHandler()` helper |
| `sse.go` | `SSEBroker`, `NewSSEBroker()`, `Publish()`, `Handler()`, `Drain()`, `ClientCount()` |
| `cache.go` | `cacheStore`, `cacheEntry`, `cacheWriter`, `cacheMiddleware()` |
| `brotli.go` | `brotliHandler`, `newBrotliHandler()`; compression level constants |
| `graphql.go` | `graphqlConfig`, `GraphQLOption`, `WithPlayground()`, `WithGraphQLPath()`, `mountGraphQL()` |
| `i18n.go` | `I18nConfig`, `WithI18n()`, `i18nMiddleware()`, `GetLocale()`, `GetMessage()` |
| `tracing.go` | `WithTracing()`, `NewTracerProvider()` |
| `swagger.go` | `swaggerSpec`, `registerSwagger()`; sequence counter for multi-instance safety |
| `openapi.go` | `SpecBuilder`, `Build()`, `buildPaths()`, `buildTags()`, `envelopeSchema()` |
| `export.go` | `ExportSpec()`, `ExportSpecToFile()` |
| `bridge.go` | `ToolDescriptor`, `ToolBridge`, `NewToolBridge()`, `Add()`, `Describe()`, `Tools()` |
| `codegen.go` | `SDKGenerator`, `Generate()`, `Available()`, `SupportedLanguages()` |
| `cmd/api/` | CLI subcommands: `core api spec` and `core api sdk` |
---
## Dependencies
### Direct
| Module | Role |
|--------|------|
| `github.com/gin-gonic/gin` | HTTP router and middleware engine |
| `github.com/gin-contrib/cors` | CORS policy middleware |
| `github.com/gin-contrib/secure` | Security headers (HSTS, X-Frame-Options, nosniff) |
| `github.com/gin-contrib/gzip` | Gzip response compression |
| `github.com/gin-contrib/slog` | Structured request logging via `log/slog` |
| `github.com/gin-contrib/timeout` | Per-request deadline enforcement |
| `github.com/gin-contrib/static` | Static file serving |
| `github.com/gin-contrib/sessions` | Cookie-backed server sessions |
| `github.com/gin-contrib/authz` | Casbin policy-based authorisation |
| `github.com/gin-contrib/httpsign` | HTTP Signatures verification |
| `github.com/gin-contrib/location/v2` | Reverse proxy header detection |
| `github.com/gin-contrib/pprof` | Go profiling endpoints |
| `github.com/gin-contrib/expvar` | Runtime metrics endpoint |
| `github.com/casbin/casbin/v2` | Policy-based access control engine |
| `github.com/coreos/go-oidc/v3` | OIDC provider discovery and JWT validation |
| `github.com/andybalholm/brotli` | Brotli compression |
| `github.com/gorilla/websocket` | WebSocket upgrade support |
| `github.com/swaggo/gin-swagger` | Swagger UI handler |
| `github.com/swaggo/files` | Swagger UI static assets |
| `github.com/swaggo/swag` | Swagger spec registry |
| `github.com/99designs/gqlgen` | GraphQL schema execution (gqlgen) |
| `go.opentelemetry.io/otel` | OpenTelemetry tracing SDK |
| `go.opentelemetry.io/contrib/.../otelgin` | OpenTelemetry Gin instrumentation |
| `golang.org/x/text` | BCP 47 language tag matching |
| `gopkg.in/yaml.v3` | YAML export of OpenAPI specs |
| `forge.lthn.ai/core/cli` | CLI command registration (for `cmd/api/` subcommands) |
### Ecosystem position
go-api sits at the base of the Lethean HTTP stack. It has no imports from other Lethean
ecosystem modules (beyond `core/cli` for the CLI subcommands). Other packages import go-api
to expose their functionality as REST endpoints:
```
Application main / Core CLI
|
v
go-api Engine <-- this module
| | |
| | +-- OpenAPI spec --> SDKGenerator --> openapi-generator-cli
| +-- ToolBridge --> go-ai / go-ml / go-rag route groups
+-- RouteGroups ----------> any package implementing RouteGroup
```
---
## Further Reading
- [Architecture](architecture.md) -- internals, key types, data flow, middleware stack
- [Development](development.md) -- building, testing, contributing, coding standards

56
export.go Normal file
View file

@ -0,0 +1,56 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// ExportSpec generates the OpenAPI spec and writes it to w.
// Format must be "json" or "yaml".
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
data, err := builder.Build(groups)
if err != nil {
return fmt.Errorf("build spec: %w", err)
}
switch format {
case "json":
_, err = w.Write(data)
return err
case "yaml":
// Unmarshal JSON then re-marshal as YAML.
var obj any
if err := json.Unmarshal(data, &obj); err != nil {
return fmt.Errorf("unmarshal spec: %w", err)
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(obj); err != nil {
return fmt.Errorf("encode yaml: %w", err)
}
return enc.Close()
default:
return fmt.Errorf("unsupported format %q: use \"json\" or \"yaml\"", format)
}
}
// ExportSpecToFile writes the spec to the given path.
// The parent directory is created if it does not exist.
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer f.Close()
return ExportSpec(f, format, builder, groups)
}

166
export_test.go Normal file
View file

@ -0,0 +1,166 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
api "forge.lthn.ai/core/api"
)
// ── ExportSpec tests ─────────────────────────────────────────────────────
func TestExportSpec_Good_JSON(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
var buf bytes.Buffer
if err := api.ExportSpec(&buf, "json", builder, nil); 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)
}
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
info := spec["info"].(map[string]any)
if info["title"] != "Test" {
t.Fatalf("expected title=Test, got %v", info["title"])
}
}
func TestExportSpec_Good_YAML(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)
}
output := buf.String()
if !strings.Contains(output, "openapi:") {
t.Fatalf("expected YAML output to contain 'openapi:', got:\n%s", output)
}
var spec map[string]any
if err := yaml.Unmarshal(buf.Bytes(), &spec); err != nil {
t.Fatalf("output is not valid YAML: %v", err)
}
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
}
func TestExportSpec_Bad_InvalidFormat(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
var buf bytes.Buffer
err := api.ExportSpec(&buf, "xml", builder, nil)
if err == nil {
t.Fatal("expected error for unsupported format, got nil")
}
if !strings.Contains(err.Error(), "unsupported format") {
t.Fatalf("expected error to contain 'unsupported format', got: %v", err)
}
}
func TestExportSpecToFile_Good_CreatesFile(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
dir := t.TempDir()
path := filepath.Join(dir, "subdir", "spec.json")
if err := api.ExportSpecToFile(path, "json", builder, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("file content is not valid JSON: %v", err)
}
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
}
func TestExportSpec_Good_WithToolBridge(t *testing.T) {
gin.SetMode(gin.TestMode)
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics",
Group: "metrics",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
var buf bytes.Buffer
if err := api.ExportSpec(&buf, "json", builder, []api.RouteGroup{bridge}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := buf.String()
if !strings.Contains(output, "/tools/file_read") {
t.Fatalf("expected output to contain /tools/file_read, got:\n%s", output)
}
if !strings.Contains(output, "/tools/metrics_query") {
t.Fatalf("expected output to contain /tools/metrics_query, got:\n%s", output)
}
// Verify it's valid JSON.
var spec map[string]any
if err := json.Unmarshal(buf.Bytes(), &spec); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
// Verify paths exist.
paths := spec["paths"].(map[string]any)
if _, ok := paths["/tools/file_read"]; !ok {
t.Fatal("expected /tools/file_read path in spec")
}
if _, ok := paths["/tools/metrics_query"]; !ok {
t.Fatal("expected /tools/metrics_query path in spec")
}
}

141
expvar_test.go Normal file
View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Expvar runtime metrics endpoint ─────────────────────────────────
func TestWithExpvar_Good_EndpointReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithExpvar())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/vars")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/vars, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Fatalf("expected application/json content type, got %q", ct)
}
}
func TestWithExpvar_Good_ContainsMemstats(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithExpvar())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/vars")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(body), "memstats") {
t.Fatal("expected response body to contain \"memstats\"")
}
}
func TestWithExpvar_Good_ContainsCmdline(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithExpvar())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/vars")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(body), "cmdline") {
t.Fatal("expected response body to contain \"cmdline\"")
}
}
func TestWithExpvar_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithRequestID(), api.WithExpvar())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/vars")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/vars with middleware, got %d", resp.StatusCode)
}
// Verify the request ID middleware is still active.
rid := resp.Header.Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID middleware")
}
}
func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/debug/vars", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for /debug/vars without WithExpvar, got %d", w.Code)
}
}

124
go.mod Normal file
View file

@ -0,0 +1,124 @@
module forge.lthn.ai/core/api
go 1.26.0
require (
forge.lthn.ai/core/cli v0.1.0
github.com/99designs/gqlgen v0.17.87
github.com/andybalholm/brotli v1.2.0
github.com/casbin/casbin/v2 v2.135.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gin-contrib/authz v1.0.6
github.com/gin-contrib/cors v1.7.6
github.com/gin-contrib/expvar v1.0.3
github.com/gin-contrib/gzip v1.2.5
github.com/gin-contrib/httpsign v1.0.3
github.com/gin-contrib/location/v2 v2.0.0
github.com/gin-contrib/pprof v1.5.3
github.com/gin-contrib/secure v1.1.2
github.com/gin-contrib/sessions v1.0.4
github.com/gin-contrib/slog v1.2.0
github.com/gin-contrib/static v1.1.5
github.com/gin-contrib/timeout v1.1.0
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
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/otel v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
forge.lthn.ai/core/go v0.1.0 // indirect
forge.lthn.ai/core/go-crypt v0.1.0 // 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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // 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/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // 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/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.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/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.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-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

341
go.sum Normal file
View file

@ -0,0 +1,341 @@
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
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/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
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/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.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
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/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
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/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
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/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
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/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/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU=
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
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/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
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.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
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.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
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.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/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/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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
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/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
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/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
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.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
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=
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/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.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
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.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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-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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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-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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

63
graphql.go Normal file
View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gin-gonic/gin"
)
// defaultGraphQLPath is the URL path where the GraphQL endpoint is mounted.
const defaultGraphQLPath = "/graphql"
// graphqlConfig holds configuration for the GraphQL endpoint.
type graphqlConfig struct {
schema graphql.ExecutableSchema
path string
playground bool
}
// GraphQLOption configures a GraphQL endpoint.
type GraphQLOption func(*graphqlConfig)
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
func WithPlayground() GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.playground = true
}
}
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
// The default path is "/graphql".
func WithGraphQLPath(path string) GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.path = path
}
}
// mountGraphQL registers the GraphQL handler and optional playground on the Gin engine.
func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) {
srv := handler.NewDefaultServer(cfg.schema)
graphqlHandler := gin.WrapH(srv)
// Mount the GraphQL endpoint for all HTTP methods (POST for queries/mutations,
// GET for playground redirects and introspection).
r.Any(cfg.path, graphqlHandler)
if cfg.playground {
playgroundPath := cfg.path + "/playground"
playgroundHandler := playground.Handler("GraphQL", cfg.path)
r.GET(playgroundPath, wrapHTTPHandler(playgroundHandler))
}
}
// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function.
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {
h.ServeHTTP(c.Writer, c.Request)
}
}

234
graphql_test.go Normal file
View file

@ -0,0 +1,234 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/99designs/gqlgen/graphql"
"github.com/gin-gonic/gin"
"github.com/vektah/gqlparser/v2"
"github.com/vektah/gqlparser/v2/ast"
api "forge.lthn.ai/core/api"
)
// newTestSchema creates a minimal ExecutableSchema that responds to { name }
// with {"name":"test"}. This avoids importing gqlgen's internal testserver
// while providing a realistic schema for handler tests.
func newTestSchema() graphql.ExecutableSchema {
schema := gqlparser.MustLoadSchema(&ast.Source{Input: `
type Query {
name: String!
}
`})
return &graphql.ExecutableSchemaMock{
SchemaFunc: func() *ast.Schema {
return schema
},
ExecFunc: func(ctx context.Context) graphql.ResponseHandler {
ran := false
return func(ctx context.Context) *graphql.Response {
if ran {
return nil
}
ran = true
return &graphql.Response{Data: []byte(`{"name":"test"}`)}
}
},
ComplexityFunc: func(_ context.Context, _, _ string, childComplexity int, _ map[string]any) (int, bool) {
return childComplexity, true
},
}
}
// ── GraphQL endpoint ──────────────────────────────────────────────────
func TestWithGraphQL_Good_EndpointResponds(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema()))
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, got %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
}
func TestWithGraphQL_Good_PlaygroundServesHTML(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithPlayground()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/graphql/playground")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected Content-Type containing text/html, got %q", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(body), "GraphQL") {
t.Fatalf("expected playground HTML containing 'GraphQL', got %q", string(body)[:200])
}
}
func TestWithGraphQL_Good_NoPlaygroundByDefault(t *testing.T) {
gin.SetMode(gin.TestMode)
// Without WithPlayground(), /graphql/playground should return 404.
e, err := api.New(api.WithGraphQL(newTestSchema()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/graphql/playground", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for /graphql/playground without WithPlayground, got %d", w.Code)
}
}
func TestWithGraphQL_Good_CustomPath(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()
// Query endpoint should be at /gql.
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 /gql, got %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
// Playground should be at /gql/playground.
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 /gql/playground, got %d", pgResp.StatusCode)
}
// The default path should not exist.
defaultResp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("default path request failed: %v", err)
}
defer defaultResp.Body.Close()
if defaultResp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 at /graphql when custom path is /gql, got %d", defaultResp.StatusCode)
}
}
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithRequestID(),
api.WithGraphQL(newTestSchema()),
)
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, got %d", resp.StatusCode)
}
// RequestID middleware should have injected the header.
reqID := resp.Header.Get("X-Request-ID")
if reqID == "" {
t.Fatal("expected X-Request-ID header from RequestID middleware")
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if !strings.Contains(string(respBody), `"name":"test"`) {
t.Fatalf("expected response containing name:test, got %q", string(respBody))
}
}

44
group.go Normal file
View file

@ -0,0 +1,44 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "github.com/gin-gonic/gin"
// RouteGroup registers API routes onto a Gin router group.
// Subsystems implement this interface to declare their endpoints.
type RouteGroup interface {
// Name returns a human-readable identifier for the group.
Name() string
// BasePath returns the URL prefix for all routes in this group.
BasePath() string
// RegisterRoutes mounts handlers onto the provided router group.
RegisterRoutes(rg *gin.RouterGroup)
}
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
type StreamGroup interface {
// Channels returns the list of channel names this group streams on.
Channels() []string
}
// DescribableGroup extends RouteGroup with OpenAPI metadata.
// RouteGroups that implement this will have their endpoints
// included in the generated OpenAPI specification.
type DescribableGroup interface {
RouteGroup
// Describe returns endpoint descriptions for OpenAPI generation.
Describe() []RouteDescription
}
// RouteDescription describes a single endpoint for OpenAPI generation.
type RouteDescription struct {
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
Path string // Path relative to BasePath, e.g. "/generate"
Summary string // Short summary
Description string // Long description
Tags []string // OpenAPI tags for grouping
RequestBody map[string]any // JSON Schema for request body (nil for GET)
Response map[string]any // JSON Schema for success response data
}

226
group_test.go Normal file
View file

@ -0,0 +1,226 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Stub implementations ────────────────────────────────────────────────
type stubGroup struct{}
func (s *stubGroup) Name() string { return "stub" }
func (s *stubGroup) BasePath() string { return "/stub" }
func (s *stubGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("pong"))
})
}
type stubStreamGroup struct {
stubGroup
}
func (s *stubStreamGroup) Channels() []string {
return []string{"events", "updates"}
}
// ── RouteGroup interface ────────────────────────────────────────────────
func TestRouteGroup_Good_InterfaceSatisfaction(t *testing.T) {
var g api.RouteGroup = &stubGroup{}
if g.Name() != "stub" {
t.Fatalf("expected Name=%q, got %q", "stub", g.Name())
}
if g.BasePath() != "/stub" {
t.Fatalf("expected BasePath=%q, got %q", "/stub", g.BasePath())
}
}
func TestRouteGroup_Good_RegisterRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
g := &stubGroup{}
rg := engine.Group(g.BasePath())
g.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
// ── StreamGroup interface ───────────────────────────────────────────────
func TestStreamGroup_Good_InterfaceSatisfaction(t *testing.T) {
var g api.StreamGroup = &stubStreamGroup{}
channels := g.Channels()
if len(channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(channels))
}
if channels[0] != "events" {
t.Fatalf("expected channels[0]=%q, got %q", "events", channels[0])
}
if channels[1] != "updates" {
t.Fatalf("expected channels[1]=%q, got %q", "updates", channels[1])
}
}
func TestStreamGroup_Good_AlsoSatisfiesRouteGroup(t *testing.T) {
sg := &stubStreamGroup{}
// A StreamGroup's embedded stubGroup should also satisfy RouteGroup.
var rg api.RouteGroup = sg
if rg.Name() != "stub" {
t.Fatalf("expected Name=%q, got %q", "stub", rg.Name())
}
}
// ── DescribableGroup interface ────────────────────────────────────────
// describableStub implements DescribableGroup for testing.
type describableStub struct {
stubGroup
descriptions []api.RouteDescription
}
func (d *describableStub) Describe() []api.RouteDescription {
return d.descriptions
}
func TestDescribableGroup_Good_ImplementsRouteGroup(t *testing.T) {
stub := &describableStub{}
// Must satisfy DescribableGroup.
var dg api.DescribableGroup = stub
if dg.Name() != "stub" {
t.Fatalf("expected Name=%q, got %q", "stub", dg.Name())
}
// Must also satisfy RouteGroup since DescribableGroup embeds it.
var rg api.RouteGroup = stub
if rg.BasePath() != "/stub" {
t.Fatalf("expected BasePath=%q, got %q", "/stub", rg.BasePath())
}
}
func TestDescribableGroup_Good_DescribeReturnsRoutes(t *testing.T) {
stub := &describableStub{
descriptions: []api.RouteDescription{
{
Method: "GET",
Path: "/items",
Summary: "List items",
Tags: []string{"items"},
},
{
Method: "POST",
Path: "/items",
Summary: "Create item",
Tags: []string{"items"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
},
},
}
var dg api.DescribableGroup = stub
descs := dg.Describe()
if len(descs) != 2 {
t.Fatalf("expected 2 descriptions, got %d", len(descs))
}
if descs[0].Method != "GET" {
t.Fatalf("expected descs[0].Method=%q, got %q", "GET", descs[0].Method)
}
if descs[0].Summary != "List items" {
t.Fatalf("expected descs[0].Summary=%q, got %q", "List items", descs[0].Summary)
}
if descs[1].Method != "POST" {
t.Fatalf("expected descs[1].Method=%q, got %q", "POST", descs[1].Method)
}
if descs[1].RequestBody == nil {
t.Fatal("expected descs[1].RequestBody to be non-nil")
}
}
func TestDescribableGroup_Good_EmptyDescribe(t *testing.T) {
stub := &describableStub{
descriptions: nil,
}
var dg api.DescribableGroup = stub
descs := dg.Describe()
if descs != nil {
t.Fatalf("expected nil descriptions, got %v", descs)
}
}
func TestDescribableGroup_Good_MultipleVerbs(t *testing.T) {
stub := &describableStub{
descriptions: []api.RouteDescription{
{Method: "GET", Path: "/resources", Summary: "List resources"},
{Method: "POST", Path: "/resources", Summary: "Create resource"},
{Method: "DELETE", Path: "/resources/:id", Summary: "Delete resource"},
},
}
var dg api.DescribableGroup = stub
descs := dg.Describe()
if len(descs) != 3 {
t.Fatalf("expected 3 descriptions, got %d", len(descs))
}
expected := []string{"GET", "POST", "DELETE"}
for i, want := range expected {
if descs[i].Method != want {
t.Fatalf("expected descs[%d].Method=%q, got %q", i, want, descs[i].Method)
}
}
}
func TestDescribableGroup_Bad_NilSchemas(t *testing.T) {
stub := &describableStub{
descriptions: []api.RouteDescription{
{
Method: "GET",
Path: "/health",
Summary: "Health check",
RequestBody: nil,
Response: nil,
},
},
}
var dg api.DescribableGroup = stub
descs := dg.Describe()
if len(descs) != 1 {
t.Fatalf("expected 1 description, got %d", len(descs))
}
if descs[0].RequestBody != nil {
t.Fatalf("expected nil RequestBody, got %v", descs[0].RequestBody)
}
if descs[0].Response != nil {
t.Fatalf("expected nil Response, got %v", descs[0].Response)
}
}

133
gzip_test.go Normal file
View file

@ -0,0 +1,133 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"compress/gzip"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── WithGzip ──────────────────────────────────────────────────────────
func TestWithGzip_Good_CompressesResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithGzip())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce)
}
}
func TestWithGzip_Good_NoCompressionWithoutAcceptHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithGzip())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Deliberately not setting Accept-Encoding header.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce == "gzip" {
t.Fatal("expected no gzip Content-Encoding when client does not request it")
}
}
func TestWithGzip_Good_DefaultLevel(t *testing.T) {
// Calling WithGzip() with no arguments should use default compression
// and not panic.
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithGzip())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q with default level, got %q", "gzip", ce)
}
}
func TestWithGzip_Good_CustomLevel(t *testing.T) {
// WithGzip(gzip.BestSpeed) should work without panicking and still compress.
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithGzip(gzip.BestSpeed))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ce := w.Header().Get("Content-Encoding")
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "gzip", ce)
}
}
func TestWithGzip_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithGzip(),
api.WithRequestID(),
)
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Accept-Encoding", "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Both gzip compression and request ID should be present.
ce := w.Header().Get("Content-Encoding")
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce)
}
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}

216
httpsign_test.go Normal file
View file

@ -0,0 +1,216 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-contrib/httpsign"
"github.com/gin-contrib/httpsign/crypto"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
const testSecretKey = "test-secret-key-for-hmac-sha256"
// testKeyID is the key ID used in HTTP signature tests.
var testKeyID = httpsign.KeyID("test-client")
// newTestSecrets builds a Secrets map with a single HMAC-SHA256 key for testing.
func newTestSecrets() httpsign.Secrets {
return httpsign.Secrets{
testKeyID: &httpsign.Secret{
Key: testSecretKey,
Algorithm: &crypto.HmacSha256{},
},
}
}
// signRequest constructs a valid HTTP Signature Authorization header for the
// given request, signing the specified headers with HMAC-SHA256 and the test
// secret key. The Date header is set to the current time if not already present.
func signRequest(req *http.Request, keyID httpsign.KeyID, secret string, headers []string) {
// Ensure a Date header exists.
if req.Header.Get("Date") == "" {
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
}
// Build the signing string in the same way the library does:
// each header as "header: value", joined by newlines.
var parts []string
for _, h := range headers {
var val string
switch h {
case "(request-target)":
val = fmt.Sprintf("%s %s", strings.ToLower(req.Method), req.URL.RequestURI())
case "host":
val = req.Host
default:
val = req.Header.Get(h)
}
parts = append(parts, fmt.Sprintf("%s: %s", h, val))
}
signingString := strings.Join(parts, "\n")
// Sign with HMAC-SHA256.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Build the Authorization header.
authValue := fmt.Sprintf(
"Signature keyId=\"%s\",algorithm=\"hmac-sha256\",headers=\"%s\",signature=\"%s\"",
keyID,
strings.Join(headers, " "),
sig,
)
req.Header.Set("Authorization", authValue)
}
// ── WithHTTPSign ──────────────────────────────────────────────────────────
func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use only (request-target) and date as required headers, disable
// validators to keep the test focused on signature verification.
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(), // no validators — pure signature check
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for validly signed request, got %d (body: %s)", w.Code, w.Body.String())
}
}
func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Sign with the wrong secret so the signature is invalid.
signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid signature, got %d", w.Code)
}
}
func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
// Send a request with no signature at all.
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for missing signature, got %d", w.Code)
}
}
func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(
api.WithRequestID(),
api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
),
)
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
// Verify that WithRequestID also ran.
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Sign with an unknown key ID that does not exist in the secrets map.
unknownKeyID := httpsign.KeyID("unknown-client")
signRequest(req, unknownKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unknown key ID, got %d", w.Code)
}
}

121
i18n.go Normal file
View file

@ -0,0 +1,121 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
)
// i18nContextKey is the Gin context key for the detected locale string.
const i18nContextKey = "i18n.locale"
// i18nMessagesKey is the Gin context key for the message lookup map.
const i18nMessagesKey = "i18n.messages"
// I18nConfig configures the internationalisation middleware.
type I18nConfig struct {
// DefaultLocale is the fallback locale when the Accept-Language header
// is absent or does not match any supported locale. Defaults to "en".
DefaultLocale string
// Supported lists the locale tags the application supports.
// Each entry should be a BCP 47 language tag (e.g. "en", "fr", "de").
// If empty, only the default locale is supported.
Supported []string
// Messages maps locale tags to key-value message pairs.
// For example: {"en": {"greeting": "Hello"}, "fr": {"greeting": "Bonjour"}}
// This is optional — handlers can use GetLocale() alone for custom logic.
Messages map[string]map[string]string
}
// WithI18n adds Accept-Language header parsing and locale detection middleware.
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
// with quality weighting support. The detected locale is stored in the Gin
// context and can be retrieved by handlers via GetLocale().
//
// If messages are configured, handlers can look up localised strings via
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
// can replace the message map later.
func WithI18n(cfg ...I18nConfig) Option {
return func(e *Engine) {
var config I18nConfig
if len(cfg) > 0 {
config = cfg[0]
}
if config.DefaultLocale == "" {
config.DefaultLocale = "en"
}
// Build the language.Matcher from supported locales.
tags := []language.Tag{language.Make(config.DefaultLocale)}
for _, s := range config.Supported {
tag := language.Make(s)
// Avoid duplicating the default if it also appears in Supported.
if tag != tags[0] {
tags = append(tags, tag)
}
}
matcher := language.NewMatcher(tags)
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config))
}
}
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
// it against supported locales, and stores the result in the context.
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
return func(c *gin.Context) {
accept := c.GetHeader("Accept-Language")
var locale string
if accept == "" {
locale = cfg.DefaultLocale
} else {
tags, _, _ := language.ParseAcceptLanguage(accept)
tag, _, _ := matcher.Match(tags...)
base, _ := tag.Base()
locale = base.String()
}
c.Set(i18nContextKey, locale)
// Attach the message map for this locale if messages are configured.
if cfg.Messages != nil {
if msgs, ok := cfg.Messages[locale]; ok {
c.Set(i18nMessagesKey, msgs)
} else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok {
// Fall back to default locale messages.
c.Set(i18nMessagesKey, msgs)
}
}
c.Next()
}
}
// GetLocale returns the detected locale for the current request.
// Returns "en" if the i18n middleware was not applied.
func GetLocale(c *gin.Context) string {
if v, ok := c.Get(i18nContextKey); ok {
if s, ok := v.(string); ok {
return s
}
}
return "en"
}
// GetMessage looks up a localised message by key for the current request.
// Returns the message string and true if found, or empty string and false
// if the key does not exist or the i18n middleware was not applied.
func GetMessage(c *gin.Context, key string) (string, bool) {
if v, ok := c.Get(i18nMessagesKey); ok {
if msgs, ok := v.(map[string]string); ok {
if msg, ok := msgs[key]; ok {
return msg, true
}
}
}
return "", false
}

226
i18n_test.go Normal file
View file

@ -0,0 +1,226 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
// i18nTestGroup provides routes that expose locale detection results.
type i18nTestGroup struct{}
func (i *i18nTestGroup) Name() string { return "i18n" }
func (i *i18nTestGroup) BasePath() string { return "/i18n" }
func (i *i18nTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/locale", func(c *gin.Context) {
locale := api.GetLocale(c)
c.JSON(http.StatusOK, api.OK(map[string]string{"locale": locale}))
})
rg.GET("/greeting", func(c *gin.Context) {
msg, ok := api.GetMessage(c, "greeting")
c.JSON(http.StatusOK, api.OK(map[string]any{
"locale": api.GetLocale(c),
"message": msg,
"found": ok,
}))
})
}
// i18nLocaleResponse is the typed response for locale detection tests.
type i18nLocaleResponse struct {
Success bool `json:"success"`
Data map[string]string `json:"data"`
}
// i18nMessageResponse is the typed response for message lookup tests.
type i18nMessageResponse struct {
Success bool `json:"success"`
Data struct {
Locale string `json:"locale"`
Message string `json:"message"`
Found bool `json:"found"`
} `json:"data"`
}
// ── Tests ───────────────────────────────────────────────────────────────
func TestWithI18n_Good_DetectsLocaleFromHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
Supported: []string{"en", "fr", "de"},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", 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 i18nLocaleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["locale"] != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"])
}
}
func TestWithI18n_Good_FallsBackToDefault(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr"},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil)
// No Accept-Language header.
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"] != "en" {
t.Fatalf("expected locale=%q, got %q", "en", resp.Data["locale"])
}
}
func TestWithI18n_Good_QualityWeighting(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
Supported: []string{"en", "fr", "de"},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil)
// French has higher quality weight than German.
req.Header.Set("Accept-Language", "de;q=0.5, fr;q=0.9")
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" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"])
}
}
func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithI18n(api.I18nConfig{
Supported: []string{"en", "fr"},
}),
api.WithRequestID(),
)
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil)
req.Header.Set("Accept-Language", "fr")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// i18n middleware should detect French.
var resp i18nLocaleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["locale"] != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"])
}
// RequestID middleware should also have run.
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
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"},
},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
// Test French message lookup.
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.Locale != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data.Locale)
}
if resp.Data.Message != "Bonjour" {
t.Fatalf("expected message=%q, got %q", "Bonjour", resp.Data.Message)
}
if !resp.Data.Found {
t.Fatal("expected found=true")
}
// Test English message lookup.
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
req.Header.Set("Accept-Language", "en")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var respEn i18nMessageResponse
if err := json.Unmarshal(w.Body.Bytes(), &respEn); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if respEn.Data.Message != "Hello" {
t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message)
}
}

180
location_test.go Normal file
View file

@ -0,0 +1,180 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-contrib/location/v2"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
// locationTestGroup exposes a route that returns the detected location.
type locationTestGroup struct{}
func (l *locationTestGroup) Name() string { return "loc" }
func (l *locationTestGroup) BasePath() string { return "/loc" }
func (l *locationTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/info", func(c *gin.Context) {
url := location.Get(c)
c.JSON(http.StatusOK, api.OK(map[string]string{
"scheme": url.Scheme,
"host": url.Host,
}))
})
}
// locationResponse is the typed response envelope for location info tests.
type locationResponse struct {
Success bool `json:"success"`
Data map[string]string `json:"data"`
}
// ── WithLocation ────────────────────────────────────────────────────────
func TestWithLocation_Good_DetectsForwardedHost(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithLocation())
e.Register(&locationTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil)
req.Header.Set("X-Forwarded-Host", "api.example.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp locationResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["host"] != "api.example.com" {
t.Fatalf("expected host=%q, got %q", "api.example.com", resp.Data["host"])
}
}
func TestWithLocation_Good_DetectsForwardedProto(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithLocation())
e.Register(&locationTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil)
req.Header.Set("X-Forwarded-Proto", "https")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp locationResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["scheme"] != "https" {
t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"])
}
}
func TestWithLocation_Good_FallsBackToRequestHost(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithLocation())
e.Register(&locationTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil)
// No X-Forwarded-* headers — middleware should fall back to defaults.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp locationResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
// Without forwarded headers the middleware falls back to its default
// scheme ("http"). The host will be either the request Host header
// value or the configured default; either way it must not be empty.
if resp.Data["scheme"] != "http" {
t.Fatalf("expected fallback scheme=%q, got %q", "http", resp.Data["scheme"])
}
if resp.Data["host"] == "" {
t.Fatal("expected a non-empty host in fallback mode")
}
}
func TestWithLocation_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithLocation(),
api.WithRequestID(),
)
e.Register(&locationTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil)
req.Header.Set("X-Forwarded-Host", "proxy.example.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Location middleware should populate the detected host.
var resp locationResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["host"] != "proxy.example.com" {
t.Fatalf("expected host=%q, got %q", "proxy.example.com", resp.Data["host"])
}
// RequestID middleware should also have run.
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithLocation_Good_BothHeadersCombined(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithLocation())
e.Register(&locationTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "secure.example.com")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp locationResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["scheme"] != "https" {
t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"])
}
if resp.Data["host"] != "secure.example.com" {
t.Fatalf("expected host=%q, got %q", "secure.example.com", resp.Data["host"])
}
}

59
middleware.go Normal file
View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
// Requests to paths in the skip list are allowed through without authentication.
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
return func(c *gin.Context) {
// Check whether the request path should bypass authentication.
for _, path := range skip {
if strings.HasPrefix(c.Request.URL.Path, path) {
c.Next()
return
}
}
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "missing authorization header"))
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token {
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
return
}
c.Next()
}
}
// requestIDMiddleware ensures every response carries an X-Request-ID header.
// If the client sends one, it is preserved; otherwise a random 16-byte hex
// string is generated. The ID is also stored in the Gin context as "request_id".
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" {
b := make([]byte, 16)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
c.Set("request_id", id)
c.Header("X-Request-ID", id)
c.Next()
}
}

220
middleware_test.go Normal file
View file

@ -0,0 +1,220 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
// mwTestGroup provides a simple /v1/secret endpoint for middleware tests.
type mwTestGroup struct{}
func (m *mwTestGroup) Name() string { return "mw-test" }
func (m *mwTestGroup) BasePath() string { return "/v1" }
func (m *mwTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/secret", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("classified"))
})
}
// ── Bearer auth ─────────────────────────────────────────────────────────
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBearerAuth("s3cret"))
e.Register(&mwTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, 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.Error == nil || resp.Error.Code != "unauthorised" {
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
}
}
func TestBearerAuth_Bad_WrongToken(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBearerAuth("s3cret"))
e.Register(&mwTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, 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.Error == nil || resp.Error.Code != "unauthorised" {
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
}
}
func TestBearerAuth_Good_CorrectToken(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBearerAuth("s3cret"))
e.Register(&mwTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
req.Header.Set("Authorization", "Bearer s3cret")
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.Data != "classified" {
t.Fatalf("expected Data=%q, got %q", "classified", resp.Data)
}
}
func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBearerAuth("s3cret"))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
// No Authorization header.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for /health, got %d", w.Code)
}
}
// ── Request ID ──────────────────────────────────────────────────────────
func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
id := w.Header().Get("X-Request-ID")
if id == "" {
t.Fatal("expected X-Request-ID header to be set")
}
// 16 bytes = 32 hex characters.
if len(id) != 32 {
t.Fatalf("expected 32-char hex ID, got %d chars: %q", len(id), id)
}
}
func TestRequestID_Good_PreservesClientID(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("X-Request-ID", "client-id-abc")
h.ServeHTTP(w, req)
id := w.Header().Get("X-Request-ID")
if id != "client-id-abc" {
t.Fatalf("expected X-Request-ID=%q, got %q", "client-id-abc", id)
}
}
// ── CORS ────────────────────────────────────────────────────────────────
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithCORS("*"))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "Authorization")
h.ServeHTTP(w, req)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Fatalf("expected 200 or 204 for preflight, got %d", w.Code)
}
origin := w.Header().Get("Access-Control-Allow-Origin")
if origin != "*" {
t.Fatalf("expected Access-Control-Allow-Origin=%q, got %q", "*", origin)
}
methods := w.Header().Get("Access-Control-Allow-Methods")
if methods == "" {
t.Fatal("expected Access-Control-Allow-Methods to be set")
}
headers := w.Header().Get("Access-Control-Allow-Headers")
if headers == "" {
t.Fatal("expected Access-Control-Allow-Headers to be set")
}
}
func TestCORS_Good_SpecificOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithCORS("https://app.example.com"))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
h.ServeHTTP(w, req)
origin := w.Header().Get("Access-Control-Allow-Origin")
if origin != "https://app.example.com" {
t.Fatalf("expected origin=%q, got %q", "https://app.example.com", origin)
}
}
func TestCORS_Bad_DisallowedOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithCORS("https://allowed.example.com"))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
req.Header.Set("Origin", "https://evil.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
h.ServeHTTP(w, req)
origin := w.Header().Get("Access-Control-Allow-Origin")
if origin != "" {
t.Fatalf("expected no Access-Control-Allow-Origin for disallowed origin, got %q", origin)
}
}

93
modernization_test.go Normal file
View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"slices"
"testing"
api "forge.lthn.ai/core/api"
)
func TestEngine_GroupsIter(t *testing.T) {
e, _ := api.New()
g1 := &healthGroup{}
e.Register(g1)
var groups []api.RouteGroup
for g := range e.GroupsIter() {
groups = append(groups, g)
}
if len(groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(groups))
}
if groups[0].Name() != "health-extra" {
t.Errorf("expected group name 'health-extra', got %q", groups[0].Name())
}
}
type streamGroupStub struct {
healthGroup
channels []string
}
func (s *streamGroupStub) Channels() []string { return s.channels }
func TestEngine_ChannelsIter(t *testing.T) {
e, _ := api.New()
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
g2 := &streamGroupStub{channels: []string{"ch3"}}
e.Register(g1)
e.Register(g2)
var channels []string
for ch := range e.ChannelsIter() {
channels = append(channels, ch)
}
expected := []string{"ch1", "ch2", "ch3"}
if !slices.Equal(channels, expected) {
t.Fatalf("expected channels %v, got %v", expected, channels)
}
}
func TestToolBridge_Iterators(t *testing.T) {
b := api.NewToolBridge("/tools")
desc := api.ToolDescriptor{Name: "test", Group: "g1"}
b.Add(desc, nil)
// Test ToolsIter
var tools []api.ToolDescriptor
for t := range b.ToolsIter() {
tools = append(tools, t)
}
if len(tools) != 1 || tools[0].Name != "test" {
t.Errorf("ToolsIter failed, got %v", tools)
}
// Test DescribeIter
var descs []api.RouteDescription
for d := range b.DescribeIter() {
descs = append(descs, d)
}
if len(descs) != 1 || descs[0].Path != "/test" {
t.Errorf("DescribeIter failed, got %v", descs)
}
}
func TestCodegen_SupportedLanguagesIter(t *testing.T) {
var langs []string
for l := range api.SupportedLanguagesIter() {
langs = append(langs, l)
}
if !slices.Contains(langs, "go") {
t.Errorf("SupportedLanguagesIter missing 'go'")
}
// Should be sorted
if !slices.IsSorted(langs) {
t.Errorf("SupportedLanguagesIter should be sorted, got %v", langs)
}
}

6
norace_test.go Normal file
View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: EUPL-1.2
//go:build !race
package api_test
const raceDetectorEnabled = false

184
openapi.go Normal file
View file

@ -0,0 +1,184 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"encoding/json"
"strings"
)
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
type SpecBuilder struct {
Title string
Description string
Version string
}
// Build generates the complete OpenAPI 3.1 JSON spec.
// Groups implementing DescribableGroup contribute endpoint documentation.
// Other groups are listed as tags only.
func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
spec := map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": sb.Title,
"description": sb.Description,
"version": sb.Version,
},
"paths": sb.buildPaths(groups),
"tags": sb.buildTags(groups),
}
// Add component schemas for the response envelope.
spec["components"] = map[string]any{
"schemas": map[string]any{
"Error": map[string]any{
"type": "object",
"properties": map[string]any{
"code": map[string]any{"type": "string"},
"message": map[string]any{"type": "string"},
"details": map[string]any{},
},
"required": []string{"code", "message"},
},
"Meta": map[string]any{
"type": "object",
"properties": map[string]any{
"request_id": map[string]any{"type": "string"},
"duration": map[string]any{"type": "string"},
"page": map[string]any{"type": "integer"},
"per_page": map[string]any{"type": "integer"},
"total": map[string]any{"type": "integer"},
},
},
},
}
return json.MarshalIndent(spec, "", " ")
}
// buildPaths generates the paths object from all DescribableGroups.
func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
paths := map[string]any{
// Built-in health endpoint.
"/health": map[string]any{
"get": map[string]any{
"summary": "Health check",
"description": "Returns server health status",
"tags": []string{"system"},
"responses": map[string]any{
"200": map[string]any{
"description": "Server is healthy",
"content": map[string]any{
"application/json": map[string]any{
"schema": envelopeSchema(map[string]any{"type": "string"}),
},
},
},
},
},
},
}
for _, g := range groups {
dg, ok := g.(DescribableGroup)
if !ok {
continue
}
for _, rd := range dg.Describe() {
fullPath := g.BasePath() + rd.Path
method := strings.ToLower(rd.Method)
operation := map[string]any{
"summary": rd.Summary,
"description": rd.Description,
"tags": rd.Tags,
"responses": map[string]any{
"200": map[string]any{
"description": "Successful response",
"content": map[string]any{
"application/json": map[string]any{
"schema": envelopeSchema(rd.Response),
},
},
},
"400": map[string]any{
"description": "Bad request",
"content": map[string]any{
"application/json": map[string]any{
"schema": envelopeSchema(nil),
},
},
},
},
}
// Add request body for methods that accept one.
if rd.RequestBody != nil && (method == "post" || method == "put" || method == "patch") {
operation["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": rd.RequestBody,
},
},
}
}
// Create or extend path item.
if existing, exists := paths[fullPath]; exists {
existing.(map[string]any)[method] = operation
} else {
paths[fullPath] = map[string]any{
method: operation,
}
}
}
}
return paths
}
// buildTags generates the tags array from all RouteGroups.
func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any {
tags := []map[string]any{
{"name": "system", "description": "System endpoints"},
}
seen := map[string]bool{"system": true}
for _, g := range groups {
name := g.Name()
if !seen[name] {
tags = append(tags, map[string]any{
"name": name,
"description": name + " endpoints",
})
seen[name] = true
}
}
return tags
}
// envelopeSchema wraps a data schema in the standard Response[T] envelope.
func envelopeSchema(dataSchema map[string]any) map[string]any {
properties := map[string]any{
"success": map[string]any{"type": "boolean"},
"error": map[string]any{
"$ref": "#/components/schemas/Error",
},
"meta": map[string]any{
"$ref": "#/components/schemas/Meta",
},
}
if dataSchema != nil {
properties["data"] = dataSchema
}
return map[string]any{
"type": "object",
"properties": properties,
"required": []string{"success"},
}
}

403
openapi_test.go Normal file
View file

@ -0,0 +1,403 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Test helpers ──────────────────────────────────────────────────────────
type specStubGroup struct {
name string
basePath string
descs []api.RouteDescription
}
func (s *specStubGroup) Name() string { return s.name }
func (s *specStubGroup) BasePath() string { return s.basePath }
func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs }
type plainStubGroup struct{}
func (plainStubGroup) Name() string { return "plain" }
func (plainStubGroup) BasePath() string { return "/plain" }
func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Empty test",
Version: "0.0.1",
}
data, err := sb.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)
}
// Verify OpenAPI version.
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
// Verify /health path exists.
paths := spec["paths"].(map[string]any)
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
// Verify system tag exists.
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "system" {
found = true
break
}
}
if !found {
t.Fatal("expected system tag in spec")
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Test API",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api/items",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/list",
Summary: "List items",
Tags: []string{"items"},
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
{
Method: "POST",
Path: "/create",
Summary: "Create item",
Description: "Creates a new item",
Tags: []string{"items"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
paths := spec["paths"].(map[string]any)
// Verify GET /api/items/list exists.
listPath, ok := paths["/api/items/list"]
if !ok {
t.Fatal("expected /api/items/list path in spec")
}
getOp := listPath.(map[string]any)["get"]
if getOp == nil {
t.Fatal("expected GET operation on /api/items/list")
}
if getOp.(map[string]any)["summary"] != "List items" {
t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"])
}
// Verify POST /api/items/create exists with request body.
createPath, ok := paths["/api/items/create"]
if !ok {
t.Fatal("expected /api/items/create path in spec")
}
postOp := createPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /api/items/create")
}
if postOp.(map[string]any)["summary"] != "Create item" {
t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
}
func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "data",
basePath: "/data",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/fetch",
Summary: "Fetch data",
Tags: []string{"data"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"value": map[string]any{"type": "string"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
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)
}
paths := spec["paths"].(map[string]any)
fetchPath := paths["/data/fetch"].(map[string]any)
getOp := fetchPath["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
content := resp200["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
// Verify envelope structure.
if schema["type"] != "object" {
t.Fatalf("expected schema type=object, got %v", schema["type"])
}
properties := schema["properties"].(map[string]any)
// Verify success field.
success := properties["success"].(map[string]any)
if success["type"] != "boolean" {
t.Fatalf("expected success.type=boolean, got %v", success["type"])
}
// Verify data field contains the original response schema.
dataField := properties["data"].(map[string]any)
if dataField["type"] != "object" {
t.Fatalf("expected data.type=object, got %v", dataField["type"])
}
dataProps := dataField["properties"].(map[string]any)
if dataProps["value"] == nil {
t.Fatal("expected data.properties.value to exist")
}
// Verify required contains "success".
required := schema["required"].([]any)
foundSuccess := false
for _, r := range required {
if r == "success" {
foundSuccess = true
break
}
}
if !foundSuccess {
t.Fatal("expected 'success' in required array")
}
}
func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
data, err := sb.Build([]api.RouteGroup{plainStubGroup{}})
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)
}
// Verify plainStubGroup appears in tags.
tags := spec["tags"].([]any)
foundPlain := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "plain" {
foundPlain = true
break
}
}
if !foundPlain {
t.Fatal("expected 'plain' tag in spec for non-describable group")
}
// Verify only /health exists in paths (plain group adds no paths).
paths := spec["paths"].(map[string]any)
if len(paths) != 1 {
t.Fatalf("expected 1 path (/health only), got %d", len(paths))
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
}
func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
sb := &api.SpecBuilder{
Title: "Tool API",
Version: "1.0.0",
}
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"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
data, err := sb.Build([]api.RouteGroup{bridge})
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)
}
paths := spec["paths"].(map[string]any)
// Verify POST /tools/file_read exists.
fileReadPath, ok := paths["/tools/file_read"]
if !ok {
t.Fatal("expected /tools/file_read path in spec")
}
postOp := fileReadPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /tools/file_read")
}
if postOp.(map[string]any)["summary"] != "Read a file from disk" {
t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"])
}
// Verify POST /tools/metrics_query exists.
metricsPath, ok := paths["/tools/metrics_query"]
if !ok {
t.Fatal("expected /tools/metrics_query path in spec")
}
metricsOp := metricsPath.(map[string]any)["post"]
if metricsOp == nil {
t.Fatal("expected POST operation on /tools/metrics_query")
}
if metricsOp.(map[string]any)["summary"] != "Query metrics data" {
t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"])
}
// Verify request body is present on both (both are POST with InputSchema).
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/file_read")
}
if metricsOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/metrics_query")
}
}
func TestSpecBuilder_Bad_InfoFields(t *testing.T) {
sb := &api.SpecBuilder{
Title: "MyAPI",
Description: "Test API",
Version: "1.0.0",
}
data, err := sb.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 := spec["info"].(map[string]any)
if info["title"] != "MyAPI" {
t.Fatalf("expected title=MyAPI, got %v", info["title"])
}
if info["description"] != "Test API" {
t.Fatalf("expected description='Test API', got %v", info["description"])
}
if info["version"] != "1.0.0" {
t.Fatalf("expected version=1.0.0, got %v", info["version"])
}
}

325
options.go Normal file
View file

@ -0,0 +1,325 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"compress/gzip"
"log/slog"
"net/http"
"slices"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/casbin/casbin/v2"
"github.com/gin-contrib/authz"
"github.com/gin-contrib/cors"
gingzip "github.com/gin-contrib/gzip"
"github.com/gin-contrib/httpsign"
"github.com/gin-contrib/location/v2"
"github.com/gin-contrib/secure"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
ginslog "github.com/gin-contrib/slog"
"github.com/gin-contrib/static"
"github.com/gin-contrib/timeout"
"github.com/gin-gonic/gin"
)
// Option configures an Engine during construction.
type Option func(*Engine)
// WithAddr sets the listen address for the server.
func WithAddr(addr string) Option {
return func(e *Engine) {
e.addr = addr
}
}
// WithBearerAuth adds bearer token authentication middleware.
// Requests to /health and paths starting with /swagger are exempt.
func WithBearerAuth(token string) Option {
return func(e *Engine) {
skip := []string{"/health", "/swagger"}
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
}
}
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
func WithRequestID() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, requestIDMiddleware())
}
}
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
// Pass "*" to allow all origins, or supply specific origin URLs.
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
func WithCORS(allowOrigins ...string) Option {
return func(e *Engine) {
cfg := cors.Config{
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
MaxAge: 12 * time.Hour,
}
if slices.Contains(allowOrigins, "*") {
cfg.AllowAllOrigins = true
}
if !cfg.AllowAllOrigins {
cfg.AllowOrigins = allowOrigins
}
e.middlewares = append(e.middlewares, cors.New(cfg))
}
}
// WithMiddleware appends arbitrary Gin middleware to the engine.
func WithMiddleware(mw ...gin.HandlerFunc) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, mw...)
}
}
// WithStatic serves static files from the given root directory at urlPrefix.
// Directory listing is disabled; only individual files are served.
// Internally this uses gin-contrib/static as Gin middleware.
func WithStatic(urlPrefix, root string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
}
}
// WithWSHandler registers a WebSocket handler at GET /ws.
// Typically this wraps a go-ws Hub.Handler().
func WithWSHandler(h http.Handler) Option {
return func(e *Engine) {
e.wsHandler = h
}
}
// WithAuthentik adds Authentik forward-auth middleware that extracts user
// identity from X-authentik-* headers set by a trusted reverse proxy.
// The middleware is permissive: unauthenticated requests are allowed through.
func WithAuthentik(cfg AuthentikConfig) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authentikMiddleware(cfg))
}
}
// WithSwagger enables the Swagger UI at /swagger/.
// The title, description, and version populate the OpenAPI info block.
func WithSwagger(title, description, version string) Option {
return func(e *Engine) {
e.swaggerTitle = title
e.swaggerDesc = description
e.swaggerVersion = version
e.swaggerEnabled = true
}
}
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
// via gin-contrib/pprof.
//
// WARNING: pprof exposes sensitive runtime data and should only be
// enabled in development or behind authentication in production.
func WithPprof() Option {
return func(e *Engine) {
e.pprofEnabled = true
}
}
// WithExpvar enables the Go runtime metrics endpoint at /debug/vars.
// The endpoint serves JSON containing memstats, cmdline, and any
// custom expvar variables registered by the application. Powered by
// gin-contrib/expvar wrapping Go's standard expvar.Handler().
//
// WARNING: expvar exposes runtime internals (memory allocation,
// goroutine counts, command-line arguments) and should only be
// enabled in development or behind authentication in production.
func WithExpvar() Option {
return func(e *Engine) {
e.expvarEnabled = true
}
}
// WithSecure adds security headers middleware via gin-contrib/secure.
// Default policy sets HSTS (1 year, includeSubDomains), X-Frame-Options DENY,
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
// SSL redirect is not enabled so the middleware works behind a reverse proxy
// that terminates TLS.
func WithSecure() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, secure.New(secure.Config{
STSSeconds: 31536000,
STSIncludeSubdomains: true,
FrameDeny: true,
ContentTypeNosniff: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
IsDevelopment: false,
}))
}
}
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
func WithGzip(level ...int) Option {
return func(e *Engine) {
l := gzip.DefaultCompression
if len(level) > 0 {
l = level[0]
}
e.middlewares = append(e.middlewares, gingzip.Gzip(l))
}
}
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
func WithBrotli(level ...int) Option {
return func(e *Engine) {
l := BrotliDefaultCompression
if len(level) > 0 {
l = level[0]
}
e.middlewares = append(e.middlewares, newBrotliHandler(l).Handle)
}
}
// WithSlog adds structured request logging middleware via gin-contrib/slog.
// Each request is logged with method, path, status code, latency, and client IP.
// If logger is nil, slog.Default() is used.
func WithSlog(logger *slog.Logger) Option {
return func(e *Engine) {
if logger == nil {
logger = slog.Default()
}
e.middlewares = append(e.middlewares, ginslog.SetLogger(
ginslog.WithLogger(func(_ *gin.Context, l *slog.Logger) *slog.Logger {
return logger
}),
))
}
}
// WithTimeout adds per-request timeout middleware via gin-contrib/timeout.
// If a handler exceeds the given duration, the request is aborted with a
// 504 Gateway Timeout carrying the standard error envelope:
//
// {"success":false,"error":{"code":"timeout","message":"Request timed out"}}
//
// A zero or negative duration effectively disables the timeout (the handler
// runs without a deadline) — this is safe and will not panic.
func WithTimeout(d time.Duration) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, timeout.New(
timeout.WithTimeout(d),
timeout.WithResponse(timeoutResponse),
))
}
}
// timeoutResponse writes a 504 Gateway Timeout with the standard error envelope.
func timeoutResponse(c *gin.Context) {
c.JSON(http.StatusGatewayTimeout, Fail("timeout", "Request timed out"))
}
// WithCache adds in-memory response caching middleware for GET requests.
// Successful (2xx) GET responses are cached for the given TTL and served
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
// and error responses pass through uncached.
func WithCache(ttl time.Duration) Option {
return func(e *Engine) {
store := newCacheStore()
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
}
}
// WithSessions adds server-side session management middleware via
// gin-contrib/sessions using a cookie-based store. The name parameter
// sets the session cookie name (e.g. "session") and secret is the key
// used for cookie signing and encryption.
func WithSessions(name string, secret []byte) Option {
return func(e *Engine) {
store := cookie.NewStore(secret)
e.middlewares = append(e.middlewares, sessions.Sessions(name, store))
}
}
// WithAuthz adds Casbin policy-based authorisation middleware via
// gin-contrib/authz. The caller provides a pre-configured Casbin enforcer
// holding the desired model and policy rules. The middleware extracts the
// subject from HTTP Basic Authentication, evaluates it against the request
// method and path, and returns 403 Forbidden when the policy denies access.
func WithAuthz(enforcer *casbin.Enforcer) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
}
}
// WithHTTPSign adds HTTP signature verification middleware via
// gin-contrib/httpsign. Incoming requests must carry a valid cryptographic
// signature in the Authorization or Signature header as defined by the HTTP
// Signatures specification (draft-cavage-http-signatures).
//
// The caller provides a key store mapping key IDs to secrets (each pairing a
// shared key with a signing algorithm). Optional httpsign.Option values may
// configure required headers or custom validators; sensible defaults apply
// when omitted (date, digest, and request-target headers are required; date
// and digest validators are enabled).
//
// Requests with a missing, malformed, or invalid signature are rejected with
// 401 Unauthorised or 400 Bad Request.
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
return func(e *Engine) {
auth := httpsign.NewAuthenticator(secrets, opts...)
e.middlewares = append(e.middlewares, auth.Authenticated())
}
}
// WithSSE registers a Server-Sent Events broker at GET /events.
// Clients connect to the endpoint and receive a streaming text/event-stream
// response. The broker manages client connections and broadcasts events
// published via its Publish method.
func WithSSE(broker *SSEBroker) Option {
return func(e *Engine) {
e.sseBroker = broker
}
}
// WithLocation adds reverse proxy header detection middleware via
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
// headers to determine the original scheme and host when the server runs
// behind a TLS-terminating reverse proxy such as Traefik.
//
// After this middleware runs, handlers can call location.Get(c) to retrieve
// a *url.URL with the detected scheme, host, and base path.
func WithLocation() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, location.Default())
}
}
// WithGraphQL mounts a GraphQL endpoint serving the given gqlgen ExecutableSchema.
// By default the endpoint is mounted at "/graphql". Use GraphQLOption helpers to
// enable the playground UI or customise the path:
//
// api.New(
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
// )
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
return func(e *Engine) {
cfg := &graphqlConfig{
schema: schema,
path: defaultGraphQLPath,
}
for _, opt := range opts {
opt(cfg)
}
e.graphql = cfg
}
}

52
pkg/provider/provider.go Normal file
View file

@ -0,0 +1,52 @@
// SPDX-Licence-Identifier: EUPL-1.2
// Package provider defines the Service Provider Framework interfaces.
//
// A Provider extends api.RouteGroup with a provider identity. Providers
// register through the existing api.Engine.Register() method, inheriting
// middleware, CORS, Swagger, and OpenAPI generation automatically.
//
// Optional interfaces (Streamable, Describable, Renderable) declare
// additional capabilities that consumers (GUI, MCP, WS hub) can discover
// via type assertion.
package provider
import (
"forge.lthn.ai/core/api"
)
// Provider extends RouteGroup with a provider identity.
// Every Provider is a RouteGroup and registers through api.Engine.Register().
type Provider interface {
api.RouteGroup // Name(), BasePath(), RegisterRoutes(*gin.RouterGroup)
}
// Streamable providers emit real-time events via WebSocket.
// The hub is injected at construction time. Channels() declares the
// event prefixes this provider will emit (e.g. "brain.*", "process.*").
type Streamable interface {
Provider
Channels() []string
}
// Describable providers expose structured route descriptions for OpenAPI.
// This extends the existing DescribableGroup interface from go-api.
type Describable interface {
Provider
api.DescribableGroup // Describe() []RouteDescription
}
// Renderable providers declare a custom element for GUI display.
type Renderable interface {
Provider
Element() ElementSpec
}
// ElementSpec describes a web component for GUI rendering.
type ElementSpec struct {
// Tag is the custom element tag name, e.g. "core-brain-panel".
Tag string `json:"tag"`
// Source is the URL or embedded path to the JS bundle.
Source string `json:"source"`
}

12
pkg/provider/proxy.go Normal file
View file

@ -0,0 +1,12 @@
// SPDX-Licence-Identifier: EUPL-1.2
package provider
// ProxyProvider will wrap polyglot (PHP/TS) providers that publish an OpenAPI
// spec and run their own HTTP handler. The Go API layer reverse-proxies to
// their endpoint.
//
// This is a Phase 3 feature. The type is declared here as a forward reference
// so the package structure is established.
//
// See the design spec SS Polyglot Providers for the full ProxyProvider contract.

146
pkg/provider/registry.go Normal file
View file

@ -0,0 +1,146 @@
// SPDX-Licence-Identifier: EUPL-1.2
package provider
import (
"iter"
"slices"
"sync"
"forge.lthn.ai/core/api"
)
// Registry collects providers and mounts them on an api.Engine.
// It is a convenience wrapper — providers could be registered directly
// via engine.Register(), but the Registry enables discovery by consumers
// (GUI, MCP) that need to query provider capabilities.
type Registry struct {
mu sync.RWMutex
providers []Provider
}
// NewRegistry creates an empty provider registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Add registers a provider. Providers are mounted in the order they are added.
func (r *Registry) Add(p Provider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers = append(r.providers, p)
}
// MountAll registers every provider with the given api.Engine.
// Each provider is passed to engine.Register(), which mounts it as a
// RouteGroup at its BasePath with all configured middleware.
func (r *Registry) MountAll(engine *api.Engine) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.providers {
engine.Register(p)
}
}
// List returns a copy of all registered providers.
func (r *Registry) List() []Provider {
r.mu.RLock()
defer r.mu.RUnlock()
return slices.Clone(r.providers)
}
// Iter returns an iterator over all registered providers.
func (r *Registry) Iter() iter.Seq[Provider] {
r.mu.RLock()
defer r.mu.RUnlock()
return slices.Values(slices.Clone(r.providers))
}
// Len returns the number of registered providers.
func (r *Registry) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.providers)
}
// Get returns a provider by name, or nil if not found.
func (r *Registry) Get(name string) Provider {
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.providers {
if p.Name() == name {
return p
}
}
return nil
}
// Streamable returns all providers that implement the Streamable interface.
func (r *Registry) Streamable() []Streamable {
r.mu.RLock()
defer r.mu.RUnlock()
var result []Streamable
for _, p := range r.providers {
if s, ok := p.(Streamable); ok {
result = append(result, s)
}
}
return result
}
// Describable returns all providers that implement the Describable interface.
func (r *Registry) Describable() []Describable {
r.mu.RLock()
defer r.mu.RUnlock()
var result []Describable
for _, p := range r.providers {
if d, ok := p.(Describable); ok {
result = append(result, d)
}
}
return result
}
// Renderable returns all providers that implement the Renderable interface.
func (r *Registry) Renderable() []Renderable {
r.mu.RLock()
defer r.mu.RUnlock()
var result []Renderable
for _, p := range r.providers {
if rv, ok := p.(Renderable); ok {
result = append(result, rv)
}
}
return result
}
// ProviderInfo is a serialisable summary of a registered provider.
type ProviderInfo struct {
Name string `json:"name"`
BasePath string `json:"basePath"`
Channels []string `json:"channels,omitempty"`
Element *ElementSpec `json:"element,omitempty"`
}
// Info returns a summary of all registered providers.
func (r *Registry) Info() []ProviderInfo {
r.mu.RLock()
defer r.mu.RUnlock()
infos := make([]ProviderInfo, 0, len(r.providers))
for _, p := range r.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
}
infos = append(infos, info)
}
return infos
}

View file

@ -0,0 +1,160 @@
// SPDX-Licence-Identifier: EUPL-1.2
package provider_test
import (
"testing"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// -- Test helpers (minimal providers) -----------------------------------------
type stubProvider struct{}
func (s *stubProvider) Name() string { return "stub" }
func (s *stubProvider) BasePath() string { return "/api/stub" }
func (s *stubProvider) RegisterRoutes(rg *gin.RouterGroup) {}
type streamableProvider struct{ stubProvider }
func (s *streamableProvider) Channels() []string { return []string{"stub.event"} }
type describableProvider struct{ stubProvider }
func (d *describableProvider) Describe() []api.RouteDescription {
return []api.RouteDescription{
{Method: "GET", Path: "/items", Summary: "List items", Tags: []string{"stub"}},
}
}
type renderableProvider struct{ stubProvider }
func (r *renderableProvider) Element() provider.ElementSpec {
return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
}
type fullProvider struct {
streamableProvider
}
func (f *fullProvider) Name() string { return "full" }
func (f *fullProvider) BasePath() string { return "/api/full" }
func (f *fullProvider) Describe() []api.RouteDescription {
return []api.RouteDescription{
{Method: "GET", Path: "/status", Summary: "Status", Tags: []string{"full"}},
}
}
func (f *fullProvider) Element() provider.ElementSpec {
return provider.ElementSpec{Tag: "core-full-panel", Source: "/assets/full.js"}
}
// -- Tests --------------------------------------------------------------------
func TestRegistry_Add_Good(t *testing.T) {
reg := provider.NewRegistry()
assert.Equal(t, 0, reg.Len())
reg.Add(&stubProvider{})
assert.Equal(t, 1, reg.Len())
reg.Add(&streamableProvider{})
assert.Equal(t, 2, reg.Len())
}
func TestRegistry_Get_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
p := reg.Get("stub")
require.NotNil(t, p)
assert.Equal(t, "stub", p.Name())
}
func TestRegistry_Get_Bad(t *testing.T) {
reg := provider.NewRegistry()
p := reg.Get("nonexistent")
assert.Nil(t, p)
}
func TestRegistry_List_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&streamableProvider{})
list := reg.List()
assert.Len(t, list, 2)
}
func TestRegistry_MountAll_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&streamableProvider{})
engine, err := api.New()
require.NoError(t, err)
reg.MountAll(engine)
assert.Len(t, engine.Groups(), 2)
}
func TestRegistry_Streamable_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{}) // not streamable
reg.Add(&streamableProvider{}) // streamable
s := reg.Streamable()
assert.Len(t, s, 1)
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
}
func TestRegistry_Describable_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{}) // not describable
reg.Add(&describableProvider{}) // describable
d := reg.Describable()
assert.Len(t, d, 1)
assert.Len(t, d[0].Describe(), 1)
}
func TestRegistry_Renderable_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{}) // not renderable
reg.Add(&renderableProvider{}) // renderable
r := reg.Renderable()
assert.Len(t, r, 1)
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
}
func TestRegistry_Info_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&fullProvider{})
infos := reg.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_Iter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&streamableProvider{})
count := 0
for range reg.Iter() {
count++
}
assert.Equal(t, 2, count)
}

124
pprof_test.go Normal file
View file

@ -0,0 +1,124 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Pprof profiling endpoints ─────────────────────────────────────────
func TestWithPprof_Good_IndexAccessible(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithPprof())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/pprof/")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/pprof/, got %d", resp.StatusCode)
}
}
func TestWithPprof_Good_ProfileEndpointExists(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithPprof())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/pprof/heap")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/pprof/heap, got %d", resp.StatusCode)
}
}
func TestWithPprof_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithRequestID(), api.WithPprof())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/pprof/")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/pprof/ with middleware, got %d", resp.StatusCode)
}
// Verify the request ID middleware is still active.
rid := resp.Header.Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID middleware")
}
}
func TestWithPprof_Bad_NotMountedWithoutOption(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/debug/pprof/", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for /debug/pprof/ without WithPprof, got %d", w.Code)
}
}
func TestWithPprof_Good_CmdlineEndpointExists(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithPprof())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/debug/pprof/cmdline")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for /debug/pprof/cmdline, got %d", resp.StatusCode)
}
}

6
race_test.go Normal file
View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: EUPL-1.2
//go:build race
package api_test
const raceDetectorEnabled = true

71
response.go Normal file
View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: EUPL-1.2
package api
// Response is the standard envelope for all API responses.
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
// Error describes a failed API request.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// Meta carries pagination and request metadata.
type Meta struct {
RequestID string `json:"request_id,omitempty"`
Duration string `json:"duration,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
}
// OK wraps data in a successful response envelope.
func OK[T any](data T) Response[T] {
return Response[T]{
Success: true,
Data: data,
}
}
// Fail creates an error response with the given code and message.
func Fail(code, message string) Response[any] {
return Response[any]{
Success: false,
Error: &Error{
Code: code,
Message: message,
},
}
}
// FailWithDetails creates an error response with additional detail payload.
func FailWithDetails(code, message string, details any) Response[any] {
return Response[any]{
Success: false,
Error: &Error{
Code: code,
Message: message,
Details: details,
},
}
}
// Paginated wraps data in a successful response with pagination metadata.
func Paginated[T any](data T, page, perPage, total int) Response[T] {
return Response[T]{
Success: true,
Data: data,
Meta: &Meta{
Page: page,
PerPage: perPage,
Total: total,
},
}
}

205
response_test.go Normal file
View file

@ -0,0 +1,205 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"testing"
api "forge.lthn.ai/core/api"
)
// ── OK ──────────────────────────────────────────────────────────────────
func TestOK_Good(t *testing.T) {
r := api.OK("hello")
if !r.Success {
t.Fatal("expected Success=true")
}
if r.Data != "hello" {
t.Fatalf("expected Data=%q, got %q", "hello", r.Data)
}
if r.Error != nil {
t.Fatal("expected Error to be nil")
}
if r.Meta != nil {
t.Fatal("expected Meta to be nil")
}
}
func TestOK_Good_StructData(t *testing.T) {
type user struct {
Name string `json:"name"`
}
r := api.OK(user{Name: "Ada"})
if !r.Success {
t.Fatal("expected Success=true")
}
if r.Data.Name != "Ada" {
t.Fatalf("expected Data.Name=%q, got %q", "Ada", r.Data.Name)
}
}
func TestOK_Good_JSONOmitsErrorAndMeta(t *testing.T) {
r := api.OK("data")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["error"]; ok {
t.Fatal("expected 'error' field to be omitted from JSON")
}
if _, ok := raw["meta"]; ok {
t.Fatal("expected 'meta' field to be omitted from JSON")
}
if _, ok := raw["success"]; !ok {
t.Fatal("expected 'success' field to be present")
}
if _, ok := raw["data"]; !ok {
t.Fatal("expected 'data' field to be present")
}
}
// ── Fail ────────────────────────────────────────────────────────────────
func TestFail_Good(t *testing.T) {
r := api.Fail("NOT_FOUND", "resource not found")
if r.Success {
t.Fatal("expected Success=false")
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if r.Error.Code != "NOT_FOUND" {
t.Fatalf("expected Code=%q, got %q", "NOT_FOUND", r.Error.Code)
}
if r.Error.Message != "resource not found" {
t.Fatalf("expected Message=%q, got %q", "resource not found", r.Error.Message)
}
if r.Error.Details != nil {
t.Fatal("expected Details to be nil")
}
}
func TestFail_Good_JSONOmitsData(t *testing.T) {
r := api.Fail("ERR", "something went wrong")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["data"]; ok {
t.Fatal("expected 'data' field to be omitted from JSON")
}
if _, ok := raw["error"]; !ok {
t.Fatal("expected 'error' field to be present")
}
}
// ── FailWithDetails ─────────────────────────────────────────────────────
func TestFailWithDetails_Good(t *testing.T) {
details := map[string]string{"field": "email", "reason": "invalid format"}
r := api.FailWithDetails("VALIDATION", "validation failed", details)
if r.Success {
t.Fatal("expected Success=false")
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if r.Error.Code != "VALIDATION" {
t.Fatalf("expected Code=%q, got %q", "VALIDATION", r.Error.Code)
}
if r.Error.Details == nil {
t.Fatal("expected Details to be non-nil")
}
}
func TestFailWithDetails_Good_JSONIncludesDetails(t *testing.T) {
r := api.FailWithDetails("ERR", "bad", "extra info")
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
errObj, ok := raw["error"].(map[string]any)
if !ok {
t.Fatal("expected 'error' to be an object")
}
if _, ok := errObj["details"]; !ok {
t.Fatal("expected 'details' field to be present in error")
}
}
// ── Paginated ───────────────────────────────────────────────────────────
func TestPaginated_Good(t *testing.T) {
items := []string{"a", "b", "c"}
r := api.Paginated(items, 2, 25, 100)
if !r.Success {
t.Fatal("expected Success=true")
}
if len(r.Data) != 3 {
t.Fatalf("expected 3 items, got %d", len(r.Data))
}
if r.Meta == nil {
t.Fatal("expected Meta to be non-nil")
}
if r.Meta.Page != 2 {
t.Fatalf("expected Page=2, got %d", r.Meta.Page)
}
if r.Meta.PerPage != 25 {
t.Fatalf("expected PerPage=25, got %d", r.Meta.PerPage)
}
if r.Meta.Total != 100 {
t.Fatalf("expected Total=100, got %d", r.Meta.Total)
}
}
func TestPaginated_Good_JSONIncludesMeta(t *testing.T) {
r := api.Paginated([]int{1}, 1, 10, 50)
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if _, ok := raw["meta"]; !ok {
t.Fatal("expected 'meta' field to be present")
}
meta := raw["meta"].(map[string]any)
if meta["page"].(float64) != 1 {
t.Fatalf("expected page=1, got %v", meta["page"])
}
if meta["per_page"].(float64) != 10 {
t.Fatalf("expected per_page=10, got %v", meta["per_page"])
}
if meta["total"].(float64) != 50 {
t.Fatalf("expected total=50, got %v", meta["total"])
}
}

185
secure_test.go Normal file
View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── WithSecure ──────────────────────────────────────────────────────────
func TestWithSecure_Good_SetsHSTSHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
sts := w.Header().Get("Strict-Transport-Security")
if sts == "" {
t.Fatal("expected Strict-Transport-Security header to be set")
}
if !strings.Contains(sts, "max-age=31536000") {
t.Fatalf("expected max-age=31536000 in STS header, got %q", sts)
}
if !strings.Contains(strings.ToLower(sts), "includesubdomains") {
t.Fatalf("expected includeSubdomains in STS header, got %q", sts)
}
}
func TestWithSecure_Good_SetsFrameOptionsDeny(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
xfo := w.Header().Get("X-Frame-Options")
if xfo != "DENY" {
t.Fatalf("expected X-Frame-Options=%q, got %q", "DENY", xfo)
}
}
func TestWithSecure_Good_SetsContentTypeNosniff(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
cto := w.Header().Get("X-Content-Type-Options")
if cto != "nosniff" {
t.Fatalf("expected X-Content-Type-Options=%q, got %q", "nosniff", cto)
}
}
func TestWithSecure_Good_SetsReferrerPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
rp := w.Header().Get("Referrer-Policy")
if rp != "strict-origin-when-cross-origin" {
t.Fatalf("expected Referrer-Policy=%q, got %q", "strict-origin-when-cross-origin", rp)
}
}
func TestWithSecure_Good_AllHeadersPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Verify all security headers are present on a regular route.
checks := map[string]string{
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for header, want := range checks {
got := w.Header().Get(header)
if got != want {
t.Errorf("header %s: expected %q, got %q", header, want, got)
}
}
sts := w.Header().Get("Strict-Transport-Security")
if sts == "" {
t.Error("expected Strict-Transport-Security header to be set")
}
}
func TestWithSecure_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithSecure(),
api.WithRequestID(),
)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Both secure headers and request ID should be present.
if w.Header().Get("X-Frame-Options") != "DENY" {
t.Fatal("expected X-Frame-Options header from WithSecure")
}
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithSecure_Bad_NoSSLRedirect(t *testing.T) {
// SSL redirect is not enabled — the middleware runs behind a TLS-terminating
// reverse proxy. Verify plain HTTP requests are not redirected.
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSecure())
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
// Should get 200, not a 301/302 redirect.
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (no SSL redirect), got %d", w.Code)
}
}
func TestWithSecure_Ugly_DoubleSecureDoesNotPanic(t *testing.T) {
// Applying WithSecure twice should not panic or cause issues.
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithSecure(),
api.WithSecure(),
)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Headers should still be correctly set.
if w.Header().Get("X-Frame-Options") != "DENY" {
t.Fatal("expected X-Frame-Options=DENY after double WithSecure")
}
}

198
sessions_test.go Normal file
View file

@ -0,0 +1,198 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
// sessionTestGroup provides /sess/set and /sess/get endpoints for session tests.
type sessionTestGroup struct{}
func (s *sessionTestGroup) Name() string { return "sess" }
func (s *sessionTestGroup) BasePath() string { return "/sess" }
func (s *sessionTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/set", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("key", "value")
session.Save()
c.JSON(http.StatusOK, api.OK("saved"))
})
rg.GET("/get", func(c *gin.Context) {
session := sessions.Default(c)
val := session.Get("key")
c.JSON(http.StatusOK, api.OK(val))
})
}
// ── WithSessions ────────────────────────────────────────────────────────
func TestWithSessions_Good_SetsSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "session" {
found = true
break
}
}
if !found {
t.Fatal("expected Set-Cookie header with name 'session'")
}
}
func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
// First request: set session value.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("set: expected 200, got %d", w1.Code)
}
// Extract the session cookie from the response.
var sessionCookie *http.Cookie
for _, c := range w1.Result().Cookies() {
if c.Name == "session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("set: expected session cookie in response")
}
// Second request: get session value, sending the cookie back.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
req2.AddCookie(sessionCookie)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w2.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
data, ok := resp.Data.(string)
if !ok || data != "value" {
t.Fatalf("expected Data=%q, got %v", "value", resp.Data)
}
}
func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, 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.Data != nil {
t.Fatalf("expected nil Data for empty session, got %v", resp.Data)
}
}
func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithSessions("session", []byte("test-secret-key!")),
api.WithRequestID(),
)
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Session cookie should be present.
found := false
for _, c := range w.Result().Cookies() {
if c.Name == "session" {
found = true
break
}
}
if !found {
t.Fatal("expected session cookie")
}
// Request ID should also be present.
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
// Applying WithSessions twice should not panic.
e, err := api.New(
api.WithSessions("session", []byte("secret-one-here!")),
api.WithSessions("session", []byte("secret-two-here!")),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}

167
slog_test.go Normal file
View file

@ -0,0 +1,167 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
)
// ── WithSlog ──────────────────────────────────────────────────────────
func TestWithSlog_Good_LogsRequestFields(t *testing.T) {
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
e, _ := api.New(api.WithSlog(logger))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
output := buf.String()
if output == "" {
t.Fatal("expected slog output, got empty string")
}
// The structured log should contain request fields.
for _, field := range []string{"status", "method", "path", "latency", "ip"} {
if !bytes.Contains(buf.Bytes(), []byte(field)) {
t.Errorf("expected log output to contain field %q, got: %s", field, output)
}
}
}
func TestWithSlog_Good_NilLoggerUsesDefault(t *testing.T) {
// Passing nil should not panic; it uses slog.Default().
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSlog(nil))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestWithSlog_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
e, _ := api.New(
api.WithSlog(logger),
api.WithRequestID(),
)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Both slog output and request ID header should be present.
if buf.Len() == 0 {
t.Fatal("expected slog output from WithSlog")
}
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithSlog_Good_Logs404Status(t *testing.T) {
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
e, _ := api.New(api.WithSlog(logger))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
output := buf.String()
if output == "" {
t.Fatal("expected slog output for 404 request")
}
// Should contain the 404 status.
if !bytes.Contains(buf.Bytes(), []byte("404")) {
t.Errorf("expected log to contain status 404, got: %s", output)
}
}
func TestWithSlog_Bad_LogsMethodAndPath(t *testing.T) {
// Verifies POST method and custom path appear in log output.
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
e, _ := api.New(api.WithSlog(logger))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/stub/ping", nil)
h.ServeHTTP(w, req)
output := buf.String()
if !bytes.Contains(buf.Bytes(), []byte("POST")) {
t.Errorf("expected log to contain method POST, got: %s", output)
}
if !bytes.Contains(buf.Bytes(), []byte("/stub/ping")) {
t.Errorf("expected log to contain path /stub/ping, got: %s", output)
}
}
func TestWithSlog_Ugly_DoubleSlogDoesNotPanic(t *testing.T) {
// Applying WithSlog twice should not panic.
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
e, _ := api.New(
api.WithSlog(logger),
api.WithSlog(logger),
)
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}

33
src/php/phpunit.xml Normal file
View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

146
src/php/src/Api/Boot.php Normal file
View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Core\Api;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Api\Documentation\DocumentationServiceProvider;
use Core\Api\RateLimit\RateLimitService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
/**
* API Module Boot.
*
* This module provides shared API controllers and middleware.
* Routes are registered centrally in routes/api.php rather than
* per-module, as API endpoints span multiple service modules.
*/
class Boot extends ServiceProvider
{
/**
* The module name.
*/
protected string $moduleName = 'api';
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
ConsoleBooting::class => 'onConsole',
];
/**
* Register any application services.
*/
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/config.php',
$this->moduleName
);
// Register RateLimitService as a singleton
$this->app->singleton(RateLimitService::class, function ($app) {
return new RateLimitService($app->make(CacheRepository::class));
});
// Register webhook services
$this->app->singleton(Services\WebhookTemplateService::class);
$this->app->singleton(Services\WebhookSecretRotationService::class);
// Register IP restriction service for API key whitelisting
$this->app->singleton(Services\IpRestrictionService::class);
// Register API Documentation provider
$this->app->register(DocumentationServiceProvider::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/Migrations');
$this->configureRateLimiting();
}
/**
* Configure rate limiters for API endpoints.
*/
protected function configureRateLimiting(): void
{
// Rate limit for webhook template operations: 30 per minute per user
RateLimiter::for('api-webhook-templates', function (Request $request) {
$user = $request->user();
return $user
? Limit::perMinute(30)->by('user:'.$user->id)
: Limit::perMinute(10)->by($request->ip());
});
// Rate limit for template preview/validation: 60 per minute per user
RateLimiter::for('api-template-preview', function (Request $request) {
$user = $request->user();
return $user
? Limit::perMinute(60)->by('user:'.$user->id)
: Limit::perMinute(20)->by($request->ip());
});
}
// -------------------------------------------------------------------------
// Event-driven handlers
// -------------------------------------------------------------------------
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->views($this->moduleName, __DIR__.'/View/Blade');
if (file_exists(__DIR__.'/Routes/admin.php')) {
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
}
$event->livewire('api.webhook-template-manager', View\Modal\Admin\WebhookTemplateManager::class);
}
public function onApiRoutes(ApiRoutesRegistering $event): void
{
// Middleware aliases registered via event
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
$event->middleware('api.scope', Middleware\CheckApiScope::class);
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
$event->middleware('api.rate', Middleware\RateLimitApi::class);
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
// Core API routes (SEO, Pixel, Entitlements, MCP)
if (file_exists(__DIR__.'/Routes/api.php')) {
$event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
}
}
public function onConsole(ConsoleBooting $event): void
{
// Register middleware aliases for CLI context (artisan route:list etc)
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
$event->middleware('api.scope', Middleware\CheckApiScope::class);
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
$event->middleware('api.rate', Middleware\RateLimitApi::class);
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
// Register console commands
$event->command(Console\Commands\CleanupExpiredGracePeriods::class);
$event->command(Console\Commands\CheckApiUsageAlerts::class);
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace Core\Api\Concerns;
use Illuminate\Http\JsonResponse;
/**
* Standardised API response helpers.
*
* Provides consistent error response format across all API endpoints.
*/
trait HasApiResponses
{
/**
* Return a no workspace response.
*/
protected function noWorkspaceResponse(): JsonResponse
{
return response()->json([
'error' => 'no_workspace',
'message' => 'No workspace found. Please select a workspace first.',
], 404);
}
/**
* Return a resource not found response.
*/
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
{
return response()->json([
'error' => 'not_found',
'message' => "{$resource} not found.",
], 404);
}
/**
* Return a feature limit reached response.
*/
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
{
return response()->json([
'error' => 'feature_limit_reached',
'message' => $message ?? 'You have reached your limit for this feature.',
'feature' => $feature,
'upgrade_url' => route('hub.usage'),
], 403);
}
/**
* Return an access denied response.
*/
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
{
return response()->json([
'error' => 'access_denied',
'message' => $message,
], 403);
}
/**
* Return a success response with message.
*/
protected function successResponse(string $message, array $data = []): JsonResponse
{
return response()->json(array_merge([
'message' => $message,
], $data));
}
/**
* Return a created response.
*/
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
{
return response()->json([
'message' => $message,
'data' => $resource,
], 201);
}
/**
* Return a validation error response.
*/
protected function validationErrorResponse(array $errors): JsonResponse
{
return response()->json([
'error' => 'validation_failed',
'message' => 'The given data was invalid.',
'errors' => $errors,
], 422);
}
/**
* Return an invalid status error response.
*
* Used when an operation cannot be performed due to the resource's current status.
*/
protected function invalidStatusResponse(string $message): JsonResponse
{
return response()->json([
'error' => 'invalid_status',
'message' => $message,
], 422);
}
/**
* Return a provider error response.
*
* Used when an external provider operation fails.
*/
protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse
{
$response = [
'error' => 'provider_error',
'message' => $message,
];
if ($provider !== null) {
$response['provider'] = $provider;
}
return response()->json($response, 400);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Core\Api\Concerns;
use Core\Tenant\Models\UserToken;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* Trait for models that can have API tokens.
*
* Provides methods to create and manage personal access tokens
* for API authentication.
*/
trait HasApiTokens
{
/**
* Get all API tokens for this user.
*
* @return HasMany<UserToken>
*/
public function tokens(): HasMany
{
return $this->hasMany(UserToken::class);
}
/**
* Create a new personal access token for the user.
*
* @param string $name Human-readable name for the token
* @param \DateTimeInterface|null $expiresAt Optional expiration date
* @return array{token: string, model: UserToken} Plain-text token and model instance
*/
public function createToken(string $name, ?\DateTimeInterface $expiresAt = null): array
{
// Generate a random 40-character token
$plainTextToken = Str::random(40);
// Hash it for storage
$hashedToken = hash('sha256', $plainTextToken);
// Create the token record
$token = $this->tokens()->create([
'name' => $name,
'token' => $hashedToken,
'expires_at' => $expiresAt,
]);
return [
'token' => $plainTextToken,
'model' => $token,
];
}
/**
* Revoke all tokens for this user.
*
* @return int Number of tokens deleted
*/
public function revokeAllTokens(): int
{
return $this->tokens()->delete();
}
/**
* Revoke a specific token by its ID.
*
* @return bool True if the token was deleted
*/
public function revokeToken(int $tokenId): bool
{
return (bool) $this->tokens()->where('id', $tokenId)->delete();
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Core\Api\Concerns;
use Illuminate\Http\Request;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
/**
* Resolve workspace from request context.
*
* Supports both API key authentication (workspace from key) and
* session authentication (workspace from user default).
*/
trait ResolvesWorkspace
{
/**
* Get the workspace from request context.
*
* Priority:
* 1. API key workspace (set by AuthenticateApiKey middleware)
* 2. Explicit workspace_id parameter
* 3. User's default workspace
*/
protected function resolveWorkspace(Request $request): ?Workspace
{
// API key auth provides workspace directly
$workspace = $request->attributes->get('workspace');
if ($workspace instanceof Workspace) {
return $workspace;
}
// Check for explicit workspace_id
$workspaceId = $request->attributes->get('workspace_id')
?? $request->input('workspace_id')
?? $request->header('X-Workspace-Id');
if ($workspaceId) {
return $this->findWorkspaceForUser($request, (int) $workspaceId);
}
// Fall back to user's default workspace
$user = $request->user();
if ($user instanceof User) {
return $user->defaultHostWorkspace();
}
return null;
}
/**
* Find a workspace by ID that the user has access to.
*/
protected function findWorkspaceForUser(Request $request, int $workspaceId): ?Workspace
{
$user = $request->user();
if (! $user instanceof User) {
return null;
}
return $user->workspaces()
->where('workspaces.id', $workspaceId)
->first();
}
/**
* Get the authentication type.
*/
protected function getAuthType(Request $request): string
{
return $request->attributes->get('auth_type', 'session');
}
/**
* Check if authenticated via API key.
*/
protected function isApiKeyAuth(Request $request): bool
{
return $this->getAuthType($request) === 'api_key';
}
}

View file

@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace Core\Api\Console\Commands;
use Core\Api\Models\ApiKey;
use Core\Api\Notifications\HighApiUsageNotification;
use Core\Api\RateLimit\RateLimitService;
use Core\Tenant\Models\Workspace;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Check API usage levels and send alerts when approaching limits.
*
* Notifies workspace owners when:
* - 80% of rate limit is used (warning)
* - 95% of rate limit is used (critical)
*
* Uses cache to prevent duplicate notifications within a cooldown period.
*/
class CheckApiUsageAlerts extends Command
{
/**
* Cache key prefix for notification cooldowns.
*/
protected const CACHE_PREFIX = 'api_usage_alert:';
/**
* Default hours between notifications of the same level.
*/
protected const DEFAULT_COOLDOWN_HOURS = 6;
/**
* The name and signature of the console command.
*/
protected $signature = 'api:check-usage-alerts
{--dry-run : Show what alerts would be sent without sending}
{--workspace= : Check a specific workspace by ID}';
/**
* The console command description.
*/
protected $description = 'Check API usage levels and send alerts when approaching rate limits';
/**
* Alert thresholds (percentage of limit).
* Loaded from config in constructor.
*/
protected array $thresholds = [];
/**
* Cooldown hours between notifications.
*/
protected int $cooldownHours;
/**
* Execute the console command.
*/
public function handle(RateLimitService $rateLimitService): int
{
// Check if alerts are enabled
if (! config('api.alerts.enabled', true)) {
$this->info('API usage alerts are disabled.');
return Command::SUCCESS;
}
// Load thresholds from config (sorted by severity, critical first)
$this->thresholds = config('api.alerts.thresholds', [
'critical' => 95,
'warning' => 80,
]);
arsort($this->thresholds);
$this->cooldownHours = config('api.alerts.cooldown_hours', self::DEFAULT_COOLDOWN_HOURS);
$dryRun = $this->option('dry-run');
$specificWorkspace = $this->option('workspace');
if ($dryRun) {
$this->warn('DRY RUN MODE - No notifications will be sent');
$this->newLine();
}
// Get workspaces with active API keys
$query = Workspace::whereHas('apiKeys', function ($q) {
$q->active();
});
if ($specificWorkspace) {
$query->where('id', $specificWorkspace);
}
$workspaces = $query->get();
if ($workspaces->isEmpty()) {
$this->info('No workspaces with active API keys found.');
return Command::SUCCESS;
}
$alertsSent = 0;
$alertsSkipped = 0;
foreach ($workspaces as $workspace) {
$result = $this->checkWorkspaceUsage($workspace, $rateLimitService, $dryRun);
$alertsSent += $result['sent'];
$alertsSkipped += $result['skipped'];
}
$this->newLine();
$this->info("Alerts sent: {$alertsSent}");
$this->info("Alerts skipped (cooldown): {$alertsSkipped}");
return Command::SUCCESS;
}
/**
* Check usage for a workspace and send alerts if needed.
*
* @return array{sent: int, skipped: int}
*/
protected function checkWorkspaceUsage(
Workspace $workspace,
RateLimitService $rateLimitService,
bool $dryRun
): array {
$sent = 0;
$skipped = 0;
// Get rate limit config for this workspace's tier
$tier = $this->getWorkspaceTier($workspace);
$limitConfig = $this->getTierLimitConfig($tier);
if (! $limitConfig) {
return ['sent' => 0, 'skipped' => 0];
}
// Check usage for each active API key
$apiKeys = $workspace->apiKeys()->active()->get();
foreach ($apiKeys as $apiKey) {
$key = $rateLimitService->buildApiKeyKey($apiKey->id);
$attempts = $rateLimitService->attempts($key, $limitConfig['window']);
$limit = (int) floor($limitConfig['limit'] * ($limitConfig['burst'] ?? 1.0));
if ($limit === 0) {
continue;
}
$percentage = ($attempts / $limit) * 100;
// Check thresholds (critical first, then warning)
foreach ($this->thresholds as $level => $threshold) {
if ($percentage >= $threshold) {
$cacheKey = $this->getCacheKey($workspace->id, $apiKey->id, $level);
if (Cache::has($cacheKey)) {
$this->line(" [SKIP] {$workspace->name} - Key {$apiKey->prefix}: {$level} (cooldown)");
$skipped++;
break; // Don't check lower thresholds
}
$this->line(" [ALERT] {$workspace->name} - Key {$apiKey->prefix}: {$level} ({$percentage}%)");
if (! $dryRun) {
$this->sendAlert($workspace, $apiKey, $level, $attempts, $limit, $limitConfig);
Cache::put($cacheKey, true, now()->addHours($this->cooldownHours));
}
$sent++;
break; // Only send one alert per key (highest severity)
}
}
}
return ['sent' => $sent, 'skipped' => $skipped];
}
/**
* Send alert notification to workspace owner.
*/
protected function sendAlert(
Workspace $workspace,
ApiKey $apiKey,
string $level,
int $currentUsage,
int $limit,
array $limitConfig
): void {
$owner = $workspace->owner();
if (! $owner) {
$this->warn(" No owner found for workspace {$workspace->name}");
return;
}
$period = $this->formatPeriod($limitConfig['window']);
$owner->notify(new HighApiUsageNotification(
workspace: $workspace,
level: $level,
currentUsage: $currentUsage,
limit: $limit,
period: $period,
));
}
/**
* Get workspace tier for rate limiting.
*/
protected function getWorkspaceTier(Workspace $workspace): string
{
// Check for active package
$package = $workspace->workspacePackages()
->active()
->with('package')
->first();
return $package?->package?->slug ?? 'free';
}
/**
* Get rate limit config for a tier.
*
* @return array{limit: int, window: int, burst: float}|null
*/
protected function getTierLimitConfig(string $tier): ?array
{
$config = config("api.rate_limits.tiers.{$tier}");
if (! $config) {
$config = config('api.rate_limits.tiers.free');
}
if (! $config) {
$config = config('api.rate_limits.authenticated');
}
if (! $config) {
return null;
}
return [
'limit' => $config['limit'] ?? $config['requests'] ?? 60,
'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60),
'burst' => $config['burst'] ?? 1.0,
];
}
/**
* Format window period for display.
*/
protected function formatPeriod(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds} seconds";
}
$minutes = $seconds / 60;
if ($minutes === 1.0) {
return 'minute';
}
if ($minutes < 60) {
return "{$minutes} minutes";
}
$hours = $minutes / 60;
if ($hours === 1.0) {
return 'hour';
}
return "{$hours} hours";
}
/**
* Get cache key for notification cooldown.
*/
protected function getCacheKey(int $workspaceId, int $apiKeyId, string $level): string
{
return self::CACHE_PREFIX."{$workspaceId}:{$apiKeyId}:{$level}";
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mod\Api\Console\Commands;
use Illuminate\Console\Command;
use Mod\Api\Services\ApiKeyService;
/**
* Clean up API keys with expired grace periods.
*
* When an API key is rotated, the old key enters a grace period where
* both keys are valid. This command revokes keys whose grace period
* has ended.
*/
class CleanupExpiredGracePeriods extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'api:cleanup-grace-periods
{--dry-run : Show what would be revoked without actually revoking}';
/**
* The console command description.
*/
protected $description = 'Revoke API keys with expired grace periods after rotation';
/**
* Execute the console command.
*/
public function handle(ApiKeyService $service): int
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN MODE - No keys will be revoked');
$this->newLine();
// Count keys that would be cleaned up
$count = \Mod\Api\Models\ApiKey::gracePeriodExpired()
->whereNull('deleted_at')
->count();
if ($count === 0) {
$this->info('No API keys with expired grace periods found.');
} else {
$this->info("Would revoke {$count} API key(s) with expired grace periods.");
}
return Command::SUCCESS;
}
$this->info('Cleaning up API keys with expired grace periods...');
$count = $service->cleanupExpiredGracePeriods();
if ($count === 0) {
$this->info('No API keys with expired grace periods found.');
} else {
$this->info("Revoked {$count} API key(s) with expired grace periods.");
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Core\Api\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Core\Api\Services\WebhookSecretRotationService;
use Core\Content\Models\ContentWebhookEndpoint;
use Core\Social\Models\Webhook;
/**
* Clean up expired webhook secret grace periods.
*
* Removes previous_secret values from webhooks where the grace period has expired.
* This command should be run periodically (e.g., daily via scheduler).
*/
class CleanupExpiredSecrets extends Command
{
protected $signature = 'webhook:cleanup-secrets
{--dry-run : Show what would be cleaned up without making changes}
{--model= : Only process a specific model (social, content)}';
protected $description = 'Clean up expired webhook secret grace periods';
/**
* Webhook model classes to process.
*
* @var array<string, string>
*/
protected array $webhookModels = [
'social' => Webhook::class,
'content' => ContentWebhookEndpoint::class,
];
public function handle(WebhookSecretRotationService $service): int
{
$dryRun = $this->option('dry-run');
$modelFilter = $this->option('model');
$this->info('Starting webhook secret cleanup...');
if ($dryRun) {
$this->warn('DRY RUN MODE - No data will be modified');
}
$startTime = microtime(true);
$totalCleaned = 0;
$modelsToProcess = $this->getModelsToProcess($modelFilter);
if (empty($modelsToProcess)) {
$this->error('No valid models to process.');
return Command::FAILURE;
}
foreach ($modelsToProcess as $name => $modelClass) {
if (! class_exists($modelClass)) {
$this->warn("Model class {$modelClass} not found, skipping...");
continue;
}
$this->info("Processing {$name} webhooks...");
if ($dryRun) {
$count = $this->countExpiredGracePeriods($modelClass, $service);
$this->line(" Would clean up: {$count} webhook(s)");
$totalCleaned += $count;
} else {
$count = $service->cleanupExpiredGracePeriods($modelClass);
$this->line(" Cleaned up: {$count} webhook(s)");
$totalCleaned += $count;
}
}
$elapsed = round(microtime(true) - $startTime, 2);
$this->newLine();
$this->info('Cleanup Summary:');
$this->line(" Total cleaned: {$totalCleaned} webhook(s)");
$this->line(" Time elapsed: {$elapsed}s");
if (! $dryRun && $totalCleaned > 0) {
Log::info('Webhook secret cleanup completed', [
'total_cleaned' => $totalCleaned,
'elapsed_seconds' => $elapsed,
]);
}
$this->info('Webhook secret cleanup complete.');
return Command::SUCCESS;
}
/**
* Get the webhook models to process based on the filter.
*
* @return array<string, string>
*/
protected function getModelsToProcess(?string $filter): array
{
if ($filter === null) {
return $this->webhookModels;
}
$filter = strtolower($filter);
if (! isset($this->webhookModels[$filter])) {
$this->error("Invalid model filter: {$filter}");
$this->line('Available models: '.implode(', ', array_keys($this->webhookModels)));
return [];
}
return [$filter => $this->webhookModels[$filter]];
}
/**
* Count webhooks with expired grace periods (for dry run).
*/
protected function countExpiredGracePeriods(string $modelClass, WebhookSecretRotationService $service): int
{
$count = 0;
$modelClass::query()
->whereNotNull('previous_secret')
->whereNotNull('secret_rotated_at')
->chunkById(100, function ($webhooks) use ($service, &$count) {
foreach ($webhooks as $webhook) {
if (! $service->isInGracePeriod($webhook)) {
$count++;
}
}
});
return $count;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Core\Api\Contracts;
/**
* Contract for webhook events that can be rendered with templates.
*
* Any event that wants to use webhook templating must implement this interface.
*/
interface WebhookEvent
{
/**
* Get the event identifier (e.g., 'post.published', 'user.created').
*/
public static function name(): string;
/**
* Get the human-readable event name for display.
*/
public static function nameLocalised(): string;
/**
* Get the event payload data.
*
* @return array<string, mixed>
*/
public function payload(): array;
/**
* Get a human-readable message describing the event.
*/
public function message(): string;
}

View file

@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Core\Api\Services\WebhookSecretRotationService;
use Core\Content\Models\ContentWebhookEndpoint;
use Core\Social\Models\Webhook;
/**
* API controller for webhook secret rotation operations.
*/
class WebhookSecretController extends Controller
{
public function __construct(
protected WebhookSecretRotationService $rotationService
) {}
/**
* Rotate a social webhook secret.
*/
public function rotateSocialSecret(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$webhook = Webhook::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
}
$validated = $request->validate([
'grace_period_seconds' => 'nullable|integer|min:300|max:604800', // 5 min to 7 days
]);
$newSecret = $this->rotationService->rotateSecret(
$webhook,
$validated['grace_period_seconds'] ?? null
);
return response()->json([
'success' => true,
'message' => 'Secret rotated successfully',
'data' => [
'secret' => $newSecret,
'status' => $this->rotationService->getSecretStatus($webhook->fresh()),
],
]);
}
/**
* Rotate a content webhook endpoint secret.
*/
public function rotateContentSecret(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
}
$validated = $request->validate([
'grace_period_seconds' => 'nullable|integer|min:300|max:604800',
]);
$newSecret = $this->rotationService->rotateSecret(
$endpoint,
$validated['grace_period_seconds'] ?? null
);
return response()->json([
'success' => true,
'message' => 'Secret rotated successfully',
'data' => [
'secret' => $newSecret,
'status' => $this->rotationService->getSecretStatus($endpoint->fresh()),
],
]);
}
/**
* Get secret rotation status for a social webhook.
*/
public function socialSecretStatus(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$webhook = Webhook::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
}
return response()->json([
'data' => $this->rotationService->getSecretStatus($webhook),
]);
}
/**
* Get secret rotation status for a content webhook endpoint.
*/
public function contentSecretStatus(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
}
return response()->json([
'data' => $this->rotationService->getSecretStatus($endpoint),
]);
}
/**
* Invalidate the previous secret for a social webhook.
*/
public function invalidateSocialPreviousSecret(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$webhook = Webhook::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
}
$this->rotationService->invalidatePreviousSecret($webhook);
return response()->json([
'success' => true,
'message' => 'Previous secret invalidated',
]);
}
/**
* Invalidate the previous secret for a content webhook endpoint.
*/
public function invalidateContentPreviousSecret(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
}
$this->rotationService->invalidatePreviousSecret($endpoint);
return response()->json([
'success' => true,
'message' => 'Previous secret invalidated',
]);
}
/**
* Update the grace period for a social webhook.
*/
public function updateSocialGracePeriod(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$webhook = Webhook::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
}
$validated = $request->validate([
'grace_period_seconds' => 'required|integer|min:300|max:604800',
]);
$this->rotationService->updateGracePeriod($webhook, $validated['grace_period_seconds']);
return response()->json([
'success' => true,
'message' => 'Grace period updated',
'data' => [
'grace_period_seconds' => $webhook->fresh()->grace_period_seconds,
],
]);
}
/**
* Update the grace period for a content webhook endpoint.
*/
public function updateContentGracePeriod(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
}
$validated = $request->validate([
'grace_period_seconds' => 'required|integer|min:300|max:604800',
]);
$this->rotationService->updateGracePeriod($endpoint, $validated['grace_period_seconds']);
return response()->json([
'success' => true,
'message' => 'Grace period updated',
'data' => [
'grace_period_seconds' => $endpoint->fresh()->grace_period_seconds,
],
]);
}
}

View file

@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Core\Api\Enums\WebhookTemplateFormat;
use Core\Api\Models\WebhookPayloadTemplate;
use Core\Api\Services\WebhookTemplateService;
/**
* API controller for managing webhook payload templates.
*/
class WebhookTemplateController extends Controller
{
public function __construct(
protected WebhookTemplateService $templateService
) {}
/**
* List all templates for the workspace.
*/
public function index(Request $request): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->active()
->ordered();
// Optional filtering
if ($request->has('builtin')) {
$request->boolean('builtin')
? $query->builtin()
: $query->custom();
}
$templates = $query->get()->map(fn ($template) => $this->formatTemplate($template));
return response()->json([
'data' => $templates,
'meta' => [
'total' => $templates->count(),
],
]);
}
/**
* Get a single template by UUID.
*/
public function show(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
}
return response()->json([
'data' => $this->formatTemplate($template, true),
]);
}
/**
* Create a new template.
*/
public function store(Request $request): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'format' => 'required|in:simple,mustache,json',
'template' => 'required|string|max:65535',
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
// Validate template syntax
$format = WebhookTemplateFormat::from($validated['format']);
$validation = $this->templateService->validateTemplate($validated['template'], $format);
if (! $validation['valid']) {
return response()->json([
'error' => 'Invalid template',
'errors' => $validation['errors'],
], 422);
}
$template = WebhookPayloadTemplate::create([
'uuid' => Str::uuid()->toString(),
'workspace_id' => $workspace->id,
'namespace_id' => $workspace->default_namespace_id ?? null,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'format' => $validated['format'],
'template' => $validated['template'],
'is_default' => $validated['is_default'] ?? false,
'is_active' => $validated['is_active'] ?? true,
]);
return response()->json([
'data' => $this->formatTemplate($template, true),
], 201);
}
/**
* Update an existing template.
*/
public function update(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
}
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'description' => 'nullable|string|max:1000',
'format' => 'sometimes|in:simple,mustache,json',
'template' => 'sometimes|string|max:65535',
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
// Validate template syntax if template is being updated
if (isset($validated['template'])) {
$format = WebhookTemplateFormat::from($validated['format'] ?? $template->format->value);
$validation = $this->templateService->validateTemplate($validated['template'], $format);
if (! $validation['valid']) {
return response()->json([
'error' => 'Invalid template',
'errors' => $validation['errors'],
], 422);
}
}
// Don't allow modifying builtin templates' format
if ($template->isBuiltin()) {
unset($validated['format']);
}
$template->update($validated);
return response()->json([
'data' => $this->formatTemplate($template->fresh(), true),
]);
}
/**
* Delete a template.
*/
public function destroy(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
}
// Don't allow deleting builtin templates
if ($template->isBuiltin()) {
return response()->json(['error' => 'Built-in templates cannot be deleted'], 403);
}
$template->delete();
return response()->json(null, 204);
}
/**
* Validate a template without saving.
*/
public function validate(Request $request): JsonResponse
{
$validated = $request->validate([
'format' => 'required|in:simple,mustache,json',
'template' => 'required|string|max:65535',
]);
$format = WebhookTemplateFormat::from($validated['format']);
$validation = $this->templateService->validateTemplate($validated['template'], $format);
return response()->json([
'valid' => $validation['valid'],
'errors' => $validation['errors'],
]);
}
/**
* Preview a template with sample data.
*/
public function preview(Request $request): JsonResponse
{
$validated = $request->validate([
'format' => 'required|in:simple,mustache,json',
'template' => 'required|string|max:65535',
'event_type' => 'nullable|string|max:100',
]);
$format = WebhookTemplateFormat::from($validated['format']);
$result = $this->templateService->previewPayload(
$validated['template'],
$format,
$validated['event_type'] ?? null
);
return response()->json($result);
}
/**
* Duplicate an existing template.
*/
public function duplicate(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
}
$newName = $request->input('name', $template->name.' (copy)');
$duplicate = $template->duplicate($newName);
return response()->json([
'data' => $this->formatTemplate($duplicate, true),
], 201);
}
/**
* Set a template as the workspace default.
*/
public function setDefault(Request $request, string $uuid): JsonResponse
{
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
->where('uuid', $uuid)
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
}
$template->setAsDefault();
return response()->json([
'data' => $this->formatTemplate($template->fresh(), true),
]);
}
/**
* Get available template variables.
*/
public function variables(Request $request): JsonResponse
{
$eventType = $request->input('event_type');
$variables = $this->templateService->getAvailableVariables($eventType);
return response()->json([
'data' => $variables,
]);
}
/**
* Get available template filters.
*/
public function filters(): JsonResponse
{
$filters = $this->templateService->getAvailableFilters();
return response()->json([
'data' => $filters,
]);
}
/**
* Get builtin template definitions.
*/
public function builtins(): JsonResponse
{
$templates = $this->templateService->getBuiltinTemplates();
return response()->json([
'data' => $templates,
]);
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Format a template for API response.
*/
protected function formatTemplate(WebhookPayloadTemplate $template, bool $includeContent = false): array
{
$data = [
'uuid' => $template->uuid,
'name' => $template->name,
'description' => $template->description,
'format' => $template->format->value,
'is_default' => $template->is_default,
'is_active' => $template->is_active,
'is_builtin' => $template->isBuiltin(),
'builtin_type' => $template->builtin_type?->value,
'created_at' => $template->created_at?->toIso8601String(),
'updated_at' => $template->updated_at?->toIso8601String(),
];
if ($includeContent) {
$data['template'] = $template->template;
$data['example_output'] = $template->example_output;
}
return $data;
}
}

View file

@ -0,0 +1,625 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers;
use Core\Front\Controller;
use Core\Api\Models\ApiKey;
use Core\Mod\Mcp\Models\McpApiRequest;
use Core\Mod\Mcp\Models\McpToolCall;
use Core\Mod\Mcp\Models\McpToolVersion;
use Core\Mod\Mcp\Services\McpWebhookDispatcher;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Yaml\Yaml;
/**
* MCP HTTP API Controller.
*
* Provides HTTP bridge to MCP servers for external integrations.
*/
class McpApiController extends Controller
{
/**
* List all available MCP servers.
*
* GET /api/v1/mcp/servers
*/
public function servers(Request $request): JsonResponse
{
$registry = $this->loadRegistry();
$servers = collect($registry['servers'] ?? [])
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
->filter()
->values();
return response()->json([
'servers' => $servers,
'count' => $servers->count(),
]);
}
/**
* Get server details with tools and resources.
*
* GET /api/v1/mcp/servers/{id}
*/
public function server(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
return response()->json($server);
}
/**
* List tools for a specific server.
*
* GET /api/v1/mcp/servers/{id}/tools
*
* Query params:
* - include_versions: bool - include version info for each tool
*/
public function tools(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
$tools = $server['tools'] ?? [];
$includeVersions = $request->boolean('include_versions', false);
// Optionally enrich tools with version information
if ($includeVersions) {
$versionService = app(ToolVersionService::class);
$tools = collect($tools)->map(function ($tool) use ($id, $versionService) {
$toolName = $tool['name'] ?? '';
$latestVersion = $versionService->getLatestVersion($id, $toolName);
$tool['versioning'] = [
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
'is_versioned' => $latestVersion !== null,
'deprecated' => $latestVersion?->is_deprecated ?? false,
];
// If version exists, use its schema (may differ from YAML)
if ($latestVersion?->input_schema) {
$tool['inputSchema'] = $latestVersion->input_schema;
}
return $tool;
})->all();
}
return response()->json([
'server' => $id,
'tools' => $tools,
'count' => count($tools),
]);
}
/**
* Execute a tool on an MCP server.
*
* POST /api/v1/mcp/tools/call
*
* Request body:
* - server: string (required)
* - tool: string (required)
* - arguments: array (optional)
* - version: string (optional) - semver version to use, defaults to latest
*/
public function callTool(Request $request): JsonResponse
{
$validated = $request->validate([
'server' => 'required|string|max:64',
'tool' => 'required|string|max:128',
'arguments' => 'nullable|array',
'version' => 'nullable|string|max:32',
]);
$server = $this->loadServerFull($validated['server']);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
// Verify tool exists in server definition
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
}
// Version resolution
$versionService = app(ToolVersionService::class);
$versionResult = $versionService->resolveVersion(
$validated['server'],
$validated['tool'],
$validated['version'] ?? null
);
// If version was requested but is sunset, block the call
if ($versionResult['error']) {
$error = $versionResult['error'];
// Sunset versions return 410 Gone
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
return response()->json([
'success' => false,
'error' => $error['message'] ?? 'Version error',
'error_code' => $error['code'] ?? 'VERSION_ERROR',
'server' => $validated['server'],
'tool' => $validated['tool'],
'requested_version' => $validated['version'] ?? null,
'latest_version' => $error['latest_version'] ?? null,
'migration_notes' => $error['migration_notes'] ?? null,
], $status);
}
/** @var McpToolVersion|null $toolVersion */
$toolVersion = $versionResult['version'];
$deprecationWarning = $versionResult['warning'];
// Use versioned schema if available for validation
$schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null;
if ($schemaForValidation) {
$validationErrors = $this->validateToolArguments(
['inputSchema' => $schemaForValidation],
$validated['arguments'] ?? []
);
if (! empty($validationErrors)) {
return response()->json([
'success' => false,
'error' => 'Validation failed',
'error_code' => 'VALIDATION_ERROR',
'validation_errors' => $validationErrors,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? 'unversioned',
], 422);
}
}
// Get API key for logging
$apiKey = $request->attributes->get('api_key');
$workspace = $apiKey?->workspace;
$startTime = microtime(true);
try {
// Execute the tool via artisan command
$result = $this->executeToolViaArtisan(
$validated['server'],
$validated['tool'],
$validated['arguments'] ?? []
);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
// Log the call
$this->logToolCall($apiKey, $validated, $result, $durationMs, true);
// Dispatch webhooks
$this->dispatchWebhook($apiKey, $validated, true, $durationMs);
$response = [
'success' => true,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
'result' => $result,
'duration_ms' => $durationMs,
];
// Include deprecation warning if applicable
if ($deprecationWarning) {
$response['_warnings'] = [$deprecationWarning];
}
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
// Build response with deprecation headers if needed
$jsonResponse = response()->json($response);
if ($deprecationWarning) {
$jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
if (isset($deprecationWarning['sunset_at'])) {
$jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
}
if (isset($deprecationWarning['latest_version'])) {
$jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']);
}
}
return $jsonResponse;
} catch (\Throwable $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
$this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage());
// Dispatch webhooks (even on failure)
$this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage());
$response = [
'success' => false,
'error' => $e->getMessage(),
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
];
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
return response()->json($response, 500);
}
}
/**
* Validate tool arguments against a JSON schema.
*
* @return array<string> Validation error messages
*/
protected function validateToolArguments(array $toolDef, array $arguments): array
{
$inputSchema = $toolDef['inputSchema'] ?? null;
if (! $inputSchema || ! is_array($inputSchema)) {
return [];
}
$errors = [];
$properties = $inputSchema['properties'] ?? [];
$required = $inputSchema['required'] ?? [];
// Check required properties
foreach ($required as $requiredProp) {
if (! array_key_exists($requiredProp, $arguments)) {
$errors[] = "Missing required argument: {$requiredProp}";
}
}
// Type validation for provided arguments
foreach ($arguments as $key => $value) {
if (! isset($properties[$key])) {
if (($inputSchema['additionalProperties'] ?? true) === false) {
$errors[] = "Unknown argument: {$key}";
}
continue;
}
$propSchema = $properties[$key];
$expectedType = $propSchema['type'] ?? null;
if ($expectedType && ! $this->validateType($value, $expectedType)) {
$errors[] = "Argument '{$key}' must be of type {$expectedType}";
}
// Validate enum values
if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) {
$allowedValues = implode(', ', $propSchema['enum']);
$errors[] = "Argument '{$key}' must be one of: {$allowedValues}";
}
}
return $errors;
}
/**
* Validate a value against a JSON Schema type.
*/
protected function validateType(mixed $value, string $type): bool
{
return match ($type) {
'string' => is_string($value),
'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value),
'number' => is_numeric($value),
'boolean' => is_bool($value),
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value) && ! array_is_list($value),
'null' => is_null($value),
default => true,
};
}
/**
* Get version history for a specific tool.
*
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions
*/
public function toolVersions(Request $request, string $server, string $tool): JsonResponse
{
$serverConfig = $this->loadServerFull($server);
if (! $serverConfig) {
return response()->json(['error' => 'Server not found'], 404);
}
// Verify tool exists in server definition
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
}
$versionService = app(ToolVersionService::class);
$versions = $versionService->getVersionHistory($server, $tool);
return response()->json([
'server' => $server,
'tool' => $tool,
'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(),
'count' => $versions->count(),
]);
}
/**
* Get a specific version of a tool.
*
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version}
*/
public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse
{
$versionService = app(ToolVersionService::class);
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
if (! $toolVersion) {
return response()->json(['error' => 'Version not found'], 404);
}
$response = response()->json($toolVersion->toApiArray());
// Add deprecation headers if applicable
if ($deprecationWarning = $toolVersion->getDeprecationWarning()) {
$response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
if (isset($deprecationWarning['sunset_at'])) {
$response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
}
}
return $response;
}
/**
* Read a resource from an MCP server.
*
* GET /api/v1/mcp/resources/{uri}
*/
public function resource(Request $request, string $uri): JsonResponse
{
// Parse URI format: server://resource/path
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
return response()->json(['error' => 'Invalid resource URI format'], 400);
}
$serverId = $matches[1];
$resourcePath = $matches[2];
$server = $this->loadServerFull($serverId);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
try {
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
return response()->json([
'uri' => $uri,
'content' => $result,
]);
} catch (\Throwable $e) {
return response()->json([
'error' => $e->getMessage(),
'uri' => $uri,
], 500);
}
}
/**
* Execute tool via artisan MCP server command.
*/
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
{
$commandMap = [
'hosthub-agent' => 'mcp:agent-server',
'socialhost' => 'mcp:socialhost-server',
'biohost' => 'mcp:biohost-server',
'commerce' => 'mcp:commerce-server',
'supporthost' => 'mcp:support-server',
'upstream' => 'mcp:upstream-server',
];
$command = $commandMap[$server] ?? null;
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
// Build MCP request
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => $arguments,
],
];
// Execute via process
$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 (isset($response['error'])) {
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
}
return $response['result'] ?? null;
}
/**
* Read resource via artisan MCP server command.
*/
protected function readResourceViaArtisan(string $server, string $path): mixed
{
// Similar to executeToolViaArtisan but with resources/read method
// Simplified for now - can expand later
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
}
/**
* Log full API request for debugging and replay.
*/
protected function logApiRequest(
Request $request,
array $validated,
int $status,
array $response,
int $durationMs,
?ApiKey $apiKey,
?string $error = null
): void {
try {
McpApiRequest::log(
method: $request->method(),
path: '/tools/call',
requestBody: $validated,
responseStatus: $status,
responseBody: $response,
durationMs: $durationMs,
workspaceId: $apiKey?->workspace_id,
apiKeyId: $apiKey?->id,
serverId: $validated['server'],
toolName: $validated['tool'],
errorMessage: $error,
ipAddress: $request->ip(),
headers: $request->headers->all()
);
} catch (\Throwable $e) {
// Don't let logging failures affect API response
report($e);
}
}
/**
* Dispatch webhook for tool execution.
*/
protected function dispatchWebhook(
?ApiKey $apiKey,
array $request,
bool $success,
int $durationMs,
?string $error = null
): void {
if (! $apiKey?->workspace_id) {
return;
}
try {
$dispatcher = new McpWebhookDispatcher;
$dispatcher->dispatchToolExecuted(
workspaceId: $apiKey->workspace_id,
serverId: $request['server'],
toolName: $request['tool'],
arguments: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error
);
} catch (\Throwable $e) {
// Don't let webhook failures affect API response
report($e);
}
}
/**
* Log tool call for analytics.
*/
protected function logToolCall(
?ApiKey $apiKey,
array $request,
mixed $result,
int $durationMs,
bool $success,
?string $error = null
): void {
McpToolCall::log(
serverId: $request['server'],
toolName: $request['tool'],
params: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error,
workspaceId: $apiKey?->workspace_id
);
}
// Registry loading methods (shared with McpRegistryController)
protected function loadRegistry(): array
{
return Cache::remember('mcp:registry', 600, function () {
$path = resource_path('mcp/registry.yaml');
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
});
}
protected function loadServerFull(string $id): ?array
{
return Cache::remember("mcp:server:{$id}", 600, function () use ($id) {
$path = resource_path("mcp/servers/{$id}.yaml");
return file_exists($path) ? Yaml::parseFile($path) : null;
});
}
protected function loadServerSummary(string $id): ?array
{
$server = $this->loadServerFull($id);
if (! $server) {
return null;
}
return [
'id' => $server['id'],
'name' => $server['name'],
'tagline' => $server['tagline'] ?? '',
'status' => $server['status'] ?? 'available',
'tool_count' => count($server['tools'] ?? []),
'resource_count' => count($server['resources'] ?? []),
];
}
}

View file

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace Mod\Api\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
/**
* Factory for generating ApiKey test instances.
*
* By default, creates keys with secure bcrypt hashing.
* Use legacyHash() to create keys with SHA-256 for migration testing.
*
* @extends Factory<ApiKey>
*/
class ApiKeyFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<ApiKey>
*/
protected $model = ApiKey::class;
/**
* Store the plain key for testing.
*/
private ?string $plainKey = null;
/**
* Define the model's default state.
*
* Creates keys with secure bcrypt hashing by default.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$plainKey = Str::random(48);
$prefix = 'hk_'.Str::random(8);
$this->plainKey = "{$prefix}_{$plainKey}";
return [
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(),
'name' => fake()->words(2, true).' API Key',
'key' => Hash::make($plainKey),
'hash_algorithm' => ApiKey::HASH_BCRYPT,
'prefix' => $prefix,
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
'server_scopes' => null,
'allowed_ips' => null,
'last_used_at' => null,
'expires_at' => null,
'grace_period_ends_at' => null,
'rotated_from_id' => null,
];
}
/**
* Get the plain key after creation.
* Must be called immediately after create() to get the plain key.
*/
public function getPlainKey(): ?string
{
return $this->plainKey;
}
/**
* Create a key with specific known credentials for testing.
*
* This method uses ApiKey::generate() which creates secure bcrypt keys.
*
* @return array{api_key: ApiKey, plain_key: string}
*/
public static function createWithPlainKey(
?Workspace $workspace = null,
?User $user = null,
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
?\DateTimeInterface $expiresAt = null
): array {
$workspace ??= Workspace::factory()->create();
$user ??= User::factory()->create();
return ApiKey::generate(
$workspace->id,
$user->id,
fake()->words(2, true).' API Key',
$scopes,
$expiresAt
);
}
/**
* Create a key with legacy SHA-256 hashing for migration testing.
*
* @return array{api_key: ApiKey, plain_key: string}
*/
public static function createLegacyKey(
?Workspace $workspace = null,
?User $user = null,
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
?\DateTimeInterface $expiresAt = null
): array {
$workspace ??= Workspace::factory()->create();
$user ??= User::factory()->create();
$plainKey = Str::random(48);
$prefix = 'hk_'.Str::random(8);
$apiKey = ApiKey::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'name' => fake()->words(2, true).' API Key',
'key' => hash('sha256', $plainKey),
'hash_algorithm' => ApiKey::HASH_SHA256,
'prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
]);
return [
'api_key' => $apiKey,
'plain_key' => "{$prefix}_{$plainKey}",
];
}
/**
* Create key with legacy SHA-256 hashing (for migration testing).
*/
public function legacyHash(): static
{
return $this->state(function (array $attributes) {
// Extract the plain key from the stored state
$parts = explode('_', $this->plainKey ?? '', 3);
$plainKey = $parts[2] ?? Str::random(48);
return [
'key' => hash('sha256', $plainKey),
'hash_algorithm' => ApiKey::HASH_SHA256,
];
});
}
/**
* Indicate that the key has been used recently.
*/
public function used(): static
{
return $this->state(fn (array $attributes) => [
'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)),
]);
}
/**
* Indicate that the key expires in the future.
*
* @param int $days Number of days until expiration
*/
public function expiresIn(int $days = 30): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->addDays($days),
]);
}
/**
* Indicate that the key has expired.
*/
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subDays(1),
]);
}
/**
* Set specific scopes.
*
* @param array<string> $scopes
*/
public function withScopes(array $scopes): static
{
return $this->state(fn (array $attributes) => [
'scopes' => $scopes,
]);
}
/**
* Set read-only scope.
*/
public function readOnly(): static
{
return $this->withScopes([ApiKey::SCOPE_READ]);
}
/**
* Set all scopes (read, write, delete).
*/
public function fullAccess(): static
{
return $this->withScopes(ApiKey::ALL_SCOPES);
}
/**
* Set specific server scopes.
*
* @param array<string>|null $servers
*/
public function withServerScopes(?array $servers): static
{
return $this->state(fn (array $attributes) => [
'server_scopes' => $servers,
]);
}
/**
* Set IP whitelist restrictions.
*
* @param array<string>|null $ips Array of IP addresses/CIDRs
*/
public function withAllowedIps(?array $ips): static
{
return $this->state(fn (array $attributes) => [
'allowed_ips' => $ips,
]);
}
/**
* Create a revoked (soft-deleted) key.
*/
public function revoked(): static
{
return $this->state(fn (array $attributes) => [
'deleted_at' => now()->subDay(),
]);
}
/**
* Create a key in a rotation grace period.
*
* @param int $hoursRemaining Hours until grace period ends
*/
public function inGracePeriod(int $hoursRemaining = 12): static
{
return $this->state(fn (array $attributes) => [
'grace_period_ends_at' => now()->addHours($hoursRemaining),
]);
}
/**
* Create a key with an expired grace period.
*/
public function gracePeriodExpired(): static
{
return $this->state(fn (array $attributes) => [
'grace_period_ends_at' => now()->subHours(1),
]);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Attributes;
use Attribute;
/**
* API Hidden attribute for excluding endpoints from documentation.
*
* Apply to controller classes or methods to hide them from the generated
* OpenAPI documentation.
*
* Example usage:
*
* // Hide entire controller
* #[ApiHidden]
* class InternalController extends Controller {}
*
* // Hide specific method
* class UserController extends Controller
* {
* #[ApiHidden]
* public function internalMethod() {}
* }
*
* // Hide with reason (for code documentation)
* #[ApiHidden('Internal use only')]
* public function debug() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class ApiHidden
{
/**
* @param string|null $reason Optional reason for hiding (documentation only)
*/
public function __construct(
public ?string $reason = null,
) {}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Attributes;
use Attribute;
/**
* API Parameter attribute for documenting endpoint parameters.
*
* Apply to controller methods to document query parameters, path parameters,
* or header parameters in OpenAPI documentation.
*
* Example usage:
*
* #[ApiParameter('page', 'query', 'integer', 'Page number', required: false, example: 1)]
* #[ApiParameter('per_page', 'query', 'integer', 'Items per page', required: false, example: 25)]
* #[ApiParameter('filter[status]', 'query', 'string', 'Filter by status', enum: ['active', 'inactive'])]
* public function index(Request $request)
* {
* // ...
* }
*
* // Document header parameters
* #[ApiParameter('X-Custom-Header', 'header', 'string', 'Custom header value')]
* public function withHeader() {}
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiParameter
{
/**
* @param string $name Parameter name
* @param string $in Parameter location: 'query', 'path', 'header', 'cookie'
* @param string $type Data type: 'string', 'integer', 'boolean', 'number', 'array'
* @param string|null $description Parameter description
* @param bool $required Whether parameter is required
* @param mixed $example Example value
* @param mixed $default Default value
* @param array|null $enum Allowed values (for enumerated parameters)
* @param string|null $format Format hint (e.g., 'date', 'email', 'uuid')
*/
public function __construct(
public string $name,
public string $in = 'query',
public string $type = 'string',
public ?string $description = null,
public bool $required = false,
public mixed $example = null,
public mixed $default = null,
public ?array $enum = null,
public ?string $format = null,
) {}
/**
* Convert to OpenAPI parameter schema.
*/
public function toSchema(): array
{
$schema = [
'type' => $this->type,
];
if ($this->format !== null) {
$schema['format'] = $this->format;
}
if ($this->enum !== null) {
$schema['enum'] = $this->enum;
}
if ($this->default !== null) {
$schema['default'] = $this->default;
}
if ($this->example !== null) {
$schema['example'] = $this->example;
}
return $schema;
}
/**
* Convert to full OpenAPI parameter object.
*/
public function toOpenApi(): array
{
$param = [
'name' => $this->name,
'in' => $this->in,
'required' => $this->required || $this->in === 'path',
'schema' => $this->toSchema(),
];
if ($this->description !== null) {
$param['description'] = $this->description;
}
return $param;
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Attributes;
use Attribute;
/**
* API Response attribute for documenting endpoint responses.
*
* Apply to controller methods to document possible responses in OpenAPI.
*
* Example usage:
*
* #[ApiResponse(200, UserResource::class, 'User retrieved successfully')]
* #[ApiResponse(404, null, 'User not found')]
* #[ApiResponse(422, null, 'Validation failed')]
* public function show(User $user)
* {
* return new UserResource($user);
* }
*
* // For paginated responses
* #[ApiResponse(200, UserResource::class, 'Users list', paginated: true)]
* public function index()
* {
* return UserResource::collection(User::paginate());
* }
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiResponse
{
/**
* @param int $status HTTP status code
* @param string|null $resource Resource class for response body (null for no body)
* @param string|null $description Description of the response
* @param bool $paginated Whether this is a paginated collection response
* @param array<string> $headers Additional response headers to document
*/
public function __construct(
public int $status,
public ?string $resource = null,
public ?string $description = null,
public bool $paginated = false,
public array $headers = [],
) {}
/**
* Get the description or generate from status code.
*/
public function getDescription(): string
{
if ($this->description !== null) {
return $this->description;
}
return match ($this->status) {
200 => 'Successful response',
201 => 'Resource created',
202 => 'Request accepted',
204 => 'No content',
301 => 'Moved permanently',
302 => 'Found (redirect)',
304 => 'Not modified',
400 => 'Bad request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not found',
405 => 'Method not allowed',
409 => 'Conflict',
422 => 'Validation error',
429 => 'Too many requests',
500 => 'Internal server error',
502 => 'Bad gateway',
503 => 'Service unavailable',
default => 'Response',
};
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Attributes;
use Attribute;
/**
* API Security attribute for documenting authentication requirements.
*
* Apply to controller classes or methods to specify authentication requirements.
*
* Example usage:
*
* // Require API key authentication
* #[ApiSecurity('apiKey')]
* class ProtectedController extends Controller {}
*
* // Require bearer token
* #[ApiSecurity('bearer')]
* public function profile() {}
*
* // Require specific scopes
* #[ApiSecurity('apiKey', scopes: ['read', 'write'])]
* public function update() {}
*
* // Mark endpoint as public (no auth required)
* #[ApiSecurity(null)]
* public function publicEndpoint() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiSecurity
{
/**
* @param string|null $scheme Security scheme name (null for no auth)
* @param array<string> $scopes Required OAuth2 scopes (if applicable)
*/
public function __construct(
public ?string $scheme,
public array $scopes = [],
) {}
/**
* Check if this marks the endpoint as public.
*/
public function isPublic(): bool
{
return $this->scheme === null;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Attributes;
use Attribute;
/**
* API Tag attribute for grouping endpoints in documentation.
*
* Apply to controller classes to group their endpoints under a specific tag
* in the OpenAPI documentation.
*
* Example usage:
*
* #[ApiTag('Users', 'User management endpoints')]
* class UserController extends Controller
* {
* // All methods will be tagged with 'Users'
* }
*
* // Or use on specific methods to override class-level tag
* #[ApiTag('Admin')]
* public function adminOnly() {}
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class ApiTag
{
/**
* @param string $name The tag name displayed in documentation
* @param string|null $description Optional description of the tag
*/
public function __construct(
public string $name,
public ?string $description = null,
) {}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\Yaml\Yaml;
/**
* API Documentation Controller.
*
* Serves OpenAPI documentation in multiple formats and provides
* interactive documentation UIs (Swagger, Scalar, ReDoc).
*/
class DocumentationController
{
public function __construct(
protected OpenApiBuilder $builder,
) {}
/**
* Show the main documentation page.
*
* Redirects to the configured default UI.
*/
public function index(Request $request): View
{
$defaultUi = config('api-docs.ui.default', 'scalar');
return match ($defaultUi) {
'swagger' => $this->swagger($request),
'redoc' => $this->redoc($request),
default => $this->scalar($request),
};
}
/**
* Show Swagger UI.
*/
public function swagger(Request $request): View
{
$config = config('api-docs.ui.swagger', []);
return view('api-docs::swagger', [
'specUrl' => route('api.docs.openapi.json'),
'config' => $config,
]);
}
/**
* Show Scalar API Reference.
*/
public function scalar(Request $request): View
{
$config = config('api-docs.ui.scalar', []);
return view('api-docs::scalar', [
'specUrl' => route('api.docs.openapi.json'),
'config' => $config,
]);
}
/**
* Show ReDoc documentation.
*/
public function redoc(Request $request): View
{
return view('api-docs::redoc', [
'specUrl' => route('api.docs.openapi.json'),
]);
}
/**
* Get OpenAPI specification as JSON.
*/
public function openApiJson(Request $request): JsonResponse
{
$spec = $this->builder->build();
return response()->json($spec)
->header('Cache-Control', $this->getCacheControl());
}
/**
* Get OpenAPI specification as YAML.
*/
public function openApiYaml(Request $request): Response
{
$spec = $this->builder->build();
// Convert to YAML
$yaml = Yaml::dump($spec, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
return response($yaml)
->header('Content-Type', 'application/x-yaml')
->header('Cache-Control', $this->getCacheControl());
}
/**
* Clear the documentation cache.
*/
public function clearCache(Request $request): JsonResponse
{
$this->builder->clearCache();
return response()->json([
'message' => 'Documentation cache cleared successfully.',
]);
}
/**
* Get cache control header value.
*/
protected function getCacheControl(): string
{
if (app()->environment('local', 'testing')) {
return 'no-cache, no-store, must-revalidate';
}
$ttl = config('api-docs.cache.ttl', 3600);
return "public, max-age={$ttl}";
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation;
use Core\Api\Documentation\Middleware\ProtectDocumentation;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
/**
* API Documentation Service Provider.
*
* Registers documentation routes, views, configuration, and services.
*/
class DocumentationServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Merge configuration
$this->mergeConfigFrom(
__DIR__.'/config.php',
'api-docs'
);
// Register OpenApiBuilder as singleton
$this->app->singleton(OpenApiBuilder::class, function ($app) {
return new OpenApiBuilder;
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Skip route registration during console commands (except route:list)
if ($this->shouldRegisterRoutes()) {
$this->registerRoutes();
}
// Register views
$this->loadViewsFrom(__DIR__.'/Views', 'api-docs');
// Publish configuration
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/config.php' => config_path('api-docs.php'),
], 'api-docs-config');
$this->publishes([
__DIR__.'/Views' => resource_path('views/vendor/api-docs'),
], 'api-docs-views');
}
}
/**
* Check if routes should be registered.
*/
protected function shouldRegisterRoutes(): bool
{
// Always register if not in console
if (! $this->app->runningInConsole()) {
return true;
}
// Register for artisan route:list command
$command = $_SERVER['argv'][1] ?? null;
return $command === 'route:list' || $command === 'route:cache';
}
/**
* Register documentation routes.
*/
protected function registerRoutes(): void
{
$path = config('api-docs.path', '/api/docs');
Route::middleware(['web', ProtectDocumentation::class])
->prefix($path)
->group(__DIR__.'/Routes/docs.php');
}
}

View file

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Examples;
/**
* Common API Examples.
*
* Provides example requests and responses for documentation.
*/
class CommonExamples
{
/**
* Get example for pagination parameters.
*/
public static function paginationParams(): array
{
return [
'page' => [
'name' => 'page',
'in' => 'query',
'description' => 'Page number for pagination',
'required' => false,
'schema' => [
'type' => 'integer',
'minimum' => 1,
'default' => 1,
'example' => 1,
],
],
'per_page' => [
'name' => 'per_page',
'in' => 'query',
'description' => 'Number of items per page',
'required' => false,
'schema' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'default' => 25,
'example' => 25,
],
],
];
}
/**
* Get example for sorting parameters.
*/
public static function sortingParams(): array
{
return [
'sort' => [
'name' => 'sort',
'in' => 'query',
'description' => 'Field to sort by (prefix with - for descending)',
'required' => false,
'schema' => [
'type' => 'string',
'example' => '-created_at',
],
],
];
}
/**
* Get example for filtering parameters.
*/
public static function filteringParams(): array
{
return [
'filter' => [
'name' => 'filter',
'in' => 'query',
'description' => 'Filter parameters in the format filter[field]=value',
'required' => false,
'style' => 'deepObject',
'explode' => true,
'schema' => [
'type' => 'object',
'additionalProperties' => [
'type' => 'string',
],
],
'example' => [
'status' => 'active',
'created_at[gte]' => '2024-01-01',
],
],
];
}
/**
* Get example paginated response.
*/
public static function paginatedResponse(string $dataExample = '[]'): array
{
return [
'data' => json_decode($dataExample, true) ?? [],
'links' => [
'first' => 'https://api.example.com/resource?page=1',
'last' => 'https://api.example.com/resource?page=10',
'prev' => null,
'next' => 'https://api.example.com/resource?page=2',
],
'meta' => [
'current_page' => 1,
'from' => 1,
'last_page' => 10,
'per_page' => 25,
'to' => 25,
'total' => 250,
],
];
}
/**
* Get example error response.
*/
public static function errorResponse(int $status, string $message, ?array $errors = null): array
{
$response = ['message' => $message];
if ($errors !== null) {
$response['errors'] = $errors;
}
return $response;
}
/**
* Get example validation error response.
*/
public static function validationErrorResponse(): array
{
return [
'message' => 'The given data was invalid.',
'errors' => [
'email' => [
'The email field is required.',
],
'name' => [
'The name field must be at least 2 characters.',
],
],
];
}
/**
* Get example rate limit headers.
*/
public static function rateLimitHeaders(int $limit = 1000, int $remaining = 999): array
{
return [
'X-RateLimit-Limit' => (string) $limit,
'X-RateLimit-Remaining' => (string) $remaining,
'X-RateLimit-Reset' => (string) (time() + 60),
];
}
/**
* Get example authentication headers.
*/
public static function authHeaders(string $type = 'api_key'): array
{
return match ($type) {
'api_key' => [
'X-API-Key' => 'hk_1234567890abcdefghijklmnop',
],
'bearer' => [
'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
],
default => [],
};
}
/**
* Get example workspace header.
*/
public static function workspaceHeader(): array
{
return [
'X-Workspace-ID' => '550e8400-e29b-41d4-a716-446655440000',
];
}
/**
* Get example CURL request.
*/
public static function curlExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$curl = "curl -X {$method} \\\n";
$curl .= " 'https://api.example.com{$endpoint}' \\\n";
foreach ($headers as $name => $value) {
$curl .= " -H '{$name}: {$value}' \\\n";
}
if ($body !== null) {
$curl .= " -H 'Content-Type: application/json' \\\n";
$curl .= " -d '".json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."'";
}
return rtrim($curl, " \\\n");
}
/**
* Get example JavaScript fetch request.
*/
public static function fetchExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$allHeaders = array_merge([
'Content-Type' => 'application/json',
], $headers);
$options = [
'method' => strtoupper($method),
'headers' => $allHeaders,
];
if ($body !== null) {
$options['body'] = 'JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT).')';
}
$code = "const response = await fetch('https://api.example.com{$endpoint}', {\n";
$code .= " method: '{$options['method']}',\n";
$code .= ' headers: '.json_encode($allHeaders, JSON_PRETTY_PRINT).",\n";
if ($body !== null) {
$code .= ' body: JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT)."),\n";
}
$code .= "});\n\n";
$code .= 'const data = await response.json();';
return $code;
}
/**
* Get example PHP request.
*/
public static function phpExample(
string $method,
string $endpoint,
?array $body = null,
array $headers = []
): string {
$code = "<?php\n\n";
$code .= "\$client = new \\GuzzleHttp\\Client();\n\n";
$code .= "\$response = \$client->request('{$method}', 'https://api.example.com{$endpoint}', [\n";
if (! empty($headers)) {
$code .= " 'headers' => [\n";
foreach ($headers as $name => $value) {
$code .= " '{$name}' => '{$value}',\n";
}
$code .= " ],\n";
}
if ($body !== null) {
$code .= " 'json' => ".var_export($body, true).",\n";
}
$code .= "]);\n\n";
$code .= '$data = json_decode($response->getBody(), true);';
return $code;
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation;
use Illuminate\Routing\Route;
/**
* OpenAPI Extension Interface.
*
* Extensions allow customizing the OpenAPI specification generation
* by modifying the spec or individual operations.
*/
interface Extension
{
/**
* Extend the complete OpenAPI specification.
*
* Called after the spec is built but before it's cached or returned.
*
* @param array $spec The OpenAPI specification array
* @param array $config Documentation configuration
* @return array Modified specification
*/
public function extend(array $spec, array $config): array;
/**
* Extend an individual operation.
*
* Called for each route operation during path building.
*
* @param array $operation The operation array
* @param Route $route The Laravel route
* @param string $method HTTP method (lowercase)
* @param array $config Documentation configuration
* @return array Modified operation
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array;
}

View file

@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* API Key Authentication Extension.
*
* Enhances API key authentication documentation with examples
* and detailed instructions.
*/
class ApiKeyAuthExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$apiKeyConfig = $config['auth']['api_key'] ?? [];
if (! ($apiKeyConfig['enabled'] ?? true)) {
return $spec;
}
// Enhance API key security scheme description
if (isset($spec['components']['securitySchemes']['apiKeyAuth'])) {
$spec['components']['securitySchemes']['apiKeyAuth']['description'] = $this->buildApiKeyDescription($apiKeyConfig);
}
// Add authentication guide to info.description
$authGuide = $this->buildAuthenticationGuide($config);
if (! empty($authGuide)) {
$spec['info']['description'] = ($spec['info']['description'] ?? '')."\n\n".$authGuide;
}
// Add example schemas for authentication-related responses
$spec['components']['schemas']['UnauthorizedError'] = [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'Unauthenticated.',
],
],
];
$spec['components']['schemas']['ForbiddenError'] = [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'This action is unauthorized.',
],
],
];
// Add common auth error responses to components
$spec['components']['responses']['Unauthorized'] = [
'description' => 'Authentication required or invalid credentials',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/UnauthorizedError',
],
'examples' => [
'missing_key' => [
'summary' => 'Missing API Key',
'value' => ['message' => 'API key is required.'],
],
'invalid_key' => [
'summary' => 'Invalid API Key',
'value' => ['message' => 'Invalid API key.'],
],
'expired_key' => [
'summary' => 'Expired API Key',
'value' => ['message' => 'API key has expired.'],
],
],
],
],
];
$spec['components']['responses']['Forbidden'] = [
'description' => 'Insufficient permissions for this action',
'content' => [
'application/json' => [
'schema' => [
'$ref' => '#/components/schemas/ForbiddenError',
],
'examples' => [
'insufficient_scope' => [
'summary' => 'Missing Required Scope',
'value' => ['message' => 'API key lacks required scope: write'],
],
'workspace_access' => [
'summary' => 'Workspace Access Denied',
'value' => ['message' => 'API key does not have access to this workspace.'],
],
],
],
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
// Add 401/403 responses to authenticated endpoints
if (! empty($operation['security'])) {
$hasApiKeyAuth = false;
foreach ($operation['security'] as $security) {
if (isset($security['apiKeyAuth'])) {
$hasApiKeyAuth = true;
break;
}
}
if ($hasApiKeyAuth) {
// Add 401 response if not present
if (! isset($operation['responses']['401'])) {
$operation['responses']['401'] = [
'$ref' => '#/components/responses/Unauthorized',
];
}
// Add 403 response if not present
if (! isset($operation['responses']['403'])) {
$operation['responses']['403'] = [
'$ref' => '#/components/responses/Forbidden',
];
}
}
}
return $operation;
}
/**
* Build detailed API key description.
*/
protected function buildApiKeyDescription(array $config): string
{
$headerName = $config['name'] ?? 'X-API-Key';
$baseDescription = $config['description'] ?? 'API key for authentication.';
return <<<MARKDOWN
$baseDescription
## Usage
Include your API key in the `$headerName` header:
```
$headerName: your_api_key_here
```
## Key Format
API keys follow the format: `hk_xxxxxxxxxxxxxxxx`
- Prefix `hk_` identifies it as a Host UK API key
- Keys are 32+ characters long
- Keys should be kept secret and never committed to version control
## Scopes
API keys can be created with specific scopes:
- `read` - Read access to resources
- `write` - Create and update resources
- `delete` - Delete resources
## Key Management
- Create and manage API keys in your workspace settings
- Keys can be revoked at any time
- Set expiration dates for temporary access
- Monitor usage via the API dashboard
MARKDOWN;
}
/**
* Build authentication guide for API description.
*/
protected function buildAuthenticationGuide(array $config): string
{
$apiKeyConfig = $config['auth']['api_key'] ?? [];
$bearerConfig = $config['auth']['bearer'] ?? [];
$sections = [];
$sections[] = '## Authentication';
$sections[] = '';
$sections[] = 'This API supports multiple authentication methods:';
$sections[] = '';
if ($apiKeyConfig['enabled'] ?? true) {
$headerName = $apiKeyConfig['name'] ?? 'X-API-Key';
$sections[] = '### API Key Authentication';
$sections[] = '';
$sections[] = "For server-to-server integration, use API key authentication via the `$headerName` header.";
$sections[] = '';
$sections[] = '```http';
$sections[] = 'GET /api/endpoint HTTP/1.1';
$sections[] = 'Host: api.example.com';
$sections[] = "$headerName: hk_your_api_key_here";
$sections[] = '```';
$sections[] = '';
}
if ($bearerConfig['enabled'] ?? true) {
$sections[] = '### Bearer Token Authentication';
$sections[] = '';
$sections[] = 'For user-authenticated requests (SPAs, mobile apps), use bearer token authentication.';
$sections[] = '';
$sections[] = '```http';
$sections[] = 'GET /api/endpoint HTTP/1.1';
$sections[] = 'Host: api.example.com';
$sections[] = 'Authorization: Bearer your_token_here';
$sections[] = '```';
$sections[] = '';
}
return implode("\n", $sections);
}
}

View file

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Core\Api\RateLimit\RateLimit;
use Illuminate\Routing\Route;
use ReflectionClass;
/**
* Rate Limit Extension.
*
* Documents rate limit headers in API responses and extracts rate limit
* information from the #[RateLimit] attribute.
*/
class RateLimitExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$rateLimitConfig = $config['rate_limits'] ?? [];
if (! ($rateLimitConfig['enabled'] ?? true)) {
return $spec;
}
// Add rate limit headers to components
$headers = $rateLimitConfig['headers'] ?? [
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
];
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
foreach ($headers as $name => $description) {
$headerKey = str_replace(['-', ' '], '', strtolower($name));
$spec['components']['headers'][$headerKey] = [
'description' => $description,
'schema' => [
'type' => 'integer',
],
];
}
// Add 429 response schema to components
$spec['components']['responses']['RateLimitExceeded'] = [
'description' => 'Rate limit exceeded',
'headers' => [
'X-RateLimit-Limit' => [
'$ref' => '#/components/headers/xratelimitlimit',
],
'X-RateLimit-Remaining' => [
'$ref' => '#/components/headers/xratelimitremaining',
],
'X-RateLimit-Reset' => [
'$ref' => '#/components/headers/xratelimitreset',
],
'Retry-After' => [
'description' => 'Seconds to wait before retrying',
'schema' => ['type' => 'integer'],
],
],
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'message' => [
'type' => 'string',
'example' => 'Too Many Requests',
],
'retry_after' => [
'type' => 'integer',
'description' => 'Seconds until rate limit resets',
'example' => 30,
],
],
],
],
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
$rateLimitConfig = $config['rate_limits'] ?? [];
if (! ($rateLimitConfig['enabled'] ?? true)) {
return $operation;
}
// Check if route has rate limiting middleware
if (! $this->hasRateLimiting($route)) {
return $operation;
}
// Add rate limit headers to successful responses
foreach ($operation['responses'] as $status => &$response) {
if ((int) $status >= 200 && (int) $status < 300) {
$response['headers'] = $response['headers'] ?? [];
$response['headers']['X-RateLimit-Limit'] = [
'$ref' => '#/components/headers/xratelimitlimit',
];
$response['headers']['X-RateLimit-Remaining'] = [
'$ref' => '#/components/headers/xratelimitremaining',
];
$response['headers']['X-RateLimit-Reset'] = [
'$ref' => '#/components/headers/xratelimitreset',
];
}
}
// Add 429 response
$operation['responses']['429'] = [
'$ref' => '#/components/responses/RateLimitExceeded',
];
// Extract rate limit from attribute and add to description
$rateLimit = $this->extractRateLimit($route);
if ($rateLimit !== null) {
$limitInfo = sprintf(
'**Rate Limit:** %d requests per %d seconds',
$rateLimit['limit'],
$rateLimit['window']
);
if ($rateLimit['burst'] > 1.0) {
$limitInfo .= sprintf(' (%.0f%% burst allowed)', ($rateLimit['burst'] - 1) * 100);
}
$operation['description'] = isset($operation['description'])
? $operation['description']."\n\n".$limitInfo
: $limitInfo;
}
return $operation;
}
/**
* Check if route has rate limiting.
*/
protected function hasRateLimiting(Route $route): bool
{
$middleware = $route->middleware();
foreach ($middleware as $m) {
if (str_contains($m, 'throttle') ||
str_contains($m, 'rate') ||
str_contains($m, 'api.rate') ||
str_contains($m, 'RateLimit')) {
return true;
}
}
// Also check for RateLimit attribute on controller
$controller = $route->getController();
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if (! empty($reflection->getAttributes(RateLimit::class))) {
return true;
}
$action = $route->getActionMethod();
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
if (! empty($method->getAttributes(RateLimit::class))) {
return true;
}
}
}
return false;
}
/**
* Extract rate limit configuration from route.
*/
protected function extractRateLimit(Route $route): ?array
{
$controller = $route->getController();
if ($controller === null) {
return null;
}
$reflection = new ReflectionClass($controller);
$action = $route->getActionMethod();
// Check method first
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$attrs = $method->getAttributes(RateLimit::class);
if (! empty($attrs)) {
$rateLimit = $attrs[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
];
}
}
// Check class
$attrs = $reflection->getAttributes(RateLimit::class);
if (! empty($attrs)) {
$rateLimit = $attrs[0]->newInstance();
return [
'limit' => $rateLimit->limit,
'window' => $rateLimit->window,
'burst' => $rateLimit->burst,
];
}
return null;
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* Workspace Header Extension.
*
* Adds documentation for the X-Workspace-ID header used in multi-tenant
* API operations.
*/
class WorkspaceHeaderExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
// Add workspace header parameter to components
$workspaceConfig = $config['workspace'] ?? [];
if (! empty($workspaceConfig)) {
$spec['components']['parameters']['workspaceId'] = [
'name' => $workspaceConfig['header_name'] ?? 'X-Workspace-ID',
'in' => 'header',
'required' => $workspaceConfig['required'] ?? false,
'description' => $workspaceConfig['description'] ?? 'Workspace identifier for multi-tenant operations',
'schema' => [
'type' => 'string',
'format' => 'uuid',
'example' => '550e8400-e29b-41d4-a716-446655440000',
],
];
}
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
// Check if route requires workspace context
if (! $this->requiresWorkspace($route)) {
return $operation;
}
$workspaceConfig = $config['workspace'] ?? [];
$headerName = $workspaceConfig['header_name'] ?? 'X-Workspace-ID';
// Add workspace header parameter reference
$operation['parameters'] = $operation['parameters'] ?? [];
// Check if already added
foreach ($operation['parameters'] as $param) {
if (isset($param['name']) && $param['name'] === $headerName) {
return $operation;
}
}
// Add as reference to component
$operation['parameters'][] = [
'$ref' => '#/components/parameters/workspaceId',
];
return $operation;
}
/**
* Check if route requires workspace context.
*/
protected function requiresWorkspace(Route $route): bool
{
$middleware = $route->middleware();
// Check for workspace-related middleware
foreach ($middleware as $m) {
if (str_contains($m, 'workspace') ||
str_contains($m, 'api.auth') ||
str_contains($m, 'auth.api')) {
return true;
}
}
// Check route name patterns that typically need workspace
$name = $route->getName() ?? '';
$workspaceRoutes = [
'api.key.',
'api.bio.',
'api.blocks.',
'api.shortlinks.',
'api.qr.',
'api.workspaces.',
'api.webhooks.',
'api.content.',
];
foreach ($workspaceRoutes as $pattern) {
if (str_starts_with($name, $pattern)) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Protect Documentation Middleware.
*
* Controls access to API documentation based on environment,
* authentication, and IP whitelist.
*/
class ProtectDocumentation
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Check if documentation is enabled
if (! config('api-docs.enabled', true)) {
abort(404);
}
$config = config('api-docs.access', []);
// Check if public access is allowed in current environment
$publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging'];
if (in_array(app()->environment(), $publicEnvironments, true)) {
return $next($request);
}
// Check IP whitelist
$ipWhitelist = $config['ip_whitelist'] ?? [];
if (! empty($ipWhitelist)) {
$clientIp = $request->ip();
if (! in_array($clientIp, $ipWhitelist, true)) {
abort(403, 'Access denied.');
}
return $next($request);
}
// Check if authentication is required
if ($config['require_auth'] ?? false) {
if (! $request->user()) {
return redirect()->route('login');
}
// Check allowed roles
$allowedRoles = $config['allowed_roles'] ?? [];
if (! empty($allowedRoles)) {
$user = $request->user();
// Check if user has any of the allowed roles
$hasRole = false;
foreach ($allowedRoles as $role) {
if (method_exists($user, 'hasRole') && $user->hasRole($role)) {
$hasRole = true;
break;
}
}
if (! $hasRole) {
abort(403, 'Insufficient permissions to view documentation.');
}
}
}
return $next($request);
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation;
use Core\Api\Documentation\Attributes\ApiTag;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
/**
* Module Discovery Service.
*
* Discovers API routes from modules and groups them by tag/module
* for organized documentation.
*/
class ModuleDiscovery
{
/**
* Discovered modules with their routes.
*
* @var array<string, array>
*/
protected array $modules = [];
/**
* Discover all API modules and their routes.
*
* @return array<string, array>
*/
public function discover(): array
{
$this->modules = [];
foreach (Route::getRoutes() as $route) {
if (! $this->isApiRoute($route)) {
continue;
}
$module = $this->identifyModule($route);
$this->addRouteToModule($module, $route);
}
ksort($this->modules);
return $this->modules;
}
/**
* Get modules grouped by tag.
*
* @return array<string, array>
*/
public function getModulesByTag(): array
{
$byTag = [];
foreach ($this->discover() as $module => $data) {
$tag = $data['tag'] ?? $module;
$byTag[$tag] = $byTag[$tag] ?? [
'name' => $tag,
'description' => $data['description'] ?? null,
'routes' => [],
];
$byTag[$tag]['routes'] = array_merge(
$byTag[$tag]['routes'],
$data['routes']
);
}
return $byTag;
}
/**
* Get a summary of discovered modules.
*/
public function getSummary(): array
{
$modules = $this->discover();
return array_map(function ($data) {
return [
'tag' => $data['tag'],
'description' => $data['description'],
'route_count' => count($data['routes']),
'endpoints' => array_map(function ($route) {
return [
'method' => $route['method'],
'uri' => $route['uri'],
'name' => $route['name'],
];
}, $data['routes']),
];
}, $modules);
}
/**
* Check if route is an API route.
*/
protected function isApiRoute($route): bool
{
$uri = $route->uri();
return str_starts_with($uri, 'api/') || $uri === 'api';
}
/**
* Identify which module a route belongs to.
*/
protected function identifyModule($route): string
{
$controller = $route->getController();
if ($controller !== null) {
// Check for ApiTag attribute
$reflection = new ReflectionClass($controller);
$tagAttrs = $reflection->getAttributes(ApiTag::class);
if (! empty($tagAttrs)) {
return $tagAttrs[0]->newInstance()->name;
}
// Infer from namespace
$namespace = $reflection->getNamespaceName();
// Extract module name from namespace patterns
if (preg_match('/(?:Mod|Module|Http\\\\Controllers)\\\\([^\\\\]+)/', $namespace, $matches)) {
return $matches[1];
}
}
// Infer from route URI
return $this->inferModuleFromUri($route->uri());
}
/**
* Infer module name from URI.
*/
protected function inferModuleFromUri(string $uri): string
{
// Remove api/ prefix
$path = preg_replace('#^api/#', '', $uri);
// Get first segment
$parts = explode('/', $path);
$segment = $parts[0] ?? 'general';
// Map common segments to module names
$mapping = [
'bio' => 'Bio',
'blocks' => 'Bio',
'shortlinks' => 'Bio',
'qr' => 'Bio',
'commerce' => 'Commerce',
'provisioning' => 'Commerce',
'workspaces' => 'Tenant',
'analytics' => 'Analytics',
'social' => 'Social',
'notify' => 'Notifications',
'support' => 'Support',
'pixel' => 'Pixel',
'seo' => 'SEO',
'mcp' => 'MCP',
'content' => 'Content',
'trust' => 'Trust',
'webhooks' => 'Webhooks',
'entitlements' => 'Entitlements',
];
return $mapping[$segment] ?? ucfirst($segment);
}
/**
* Add a route to a module.
*/
protected function addRouteToModule(string $module, $route): void
{
if (! isset($this->modules[$module])) {
$this->modules[$module] = [
'tag' => $module,
'description' => $this->getModuleDescription($module),
'routes' => [],
];
}
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
foreach ($methods as $method) {
$this->modules[$module]['routes'][] = [
'method' => strtoupper($method),
'uri' => '/'.$route->uri(),
'name' => $route->getName(),
'action' => $route->getActionMethod(),
'middleware' => $route->middleware(),
];
}
}
/**
* Get module description from config.
*/
protected function getModuleDescription(string $module): ?string
{
$tags = config('api-docs.tags', []);
return $tags[$module]['description'] ?? null;
}
}

View file

@ -0,0 +1,819 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation;
use Core\Api\Documentation\Attributes\ApiHidden;
use Core\Api\Documentation\Attributes\ApiParameter;
use Core\Api\Documentation\Attributes\ApiResponse;
use Core\Api\Documentation\Attributes\ApiSecurity;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Api\Documentation\Extensions\RateLimitExtension;
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use ReflectionAttribute;
use ReflectionClass;
/**
* Enhanced OpenAPI Specification Builder.
*
* Builds comprehensive OpenAPI 3.1 specification from Laravel routes,
* with support for custom attributes, module discovery, and extensions.
*/
class OpenApiBuilder
{
/**
* Registered extensions.
*
* @var array<Extension>
*/
protected array $extensions = [];
/**
* Discovered tags from modules.
*
* @var array<string, array>
*/
protected array $discoveredTags = [];
/**
* Create a new builder instance.
*/
public function __construct()
{
$this->registerDefaultExtensions();
}
/**
* Register default extensions.
*/
protected function registerDefaultExtensions(): void
{
$this->extensions = [
new WorkspaceHeaderExtension,
new RateLimitExtension,
new ApiKeyAuthExtension,
];
}
/**
* Add a custom extension.
*/
public function addExtension(Extension $extension): static
{
$this->extensions[] = $extension;
return $this;
}
/**
* Generate the complete OpenAPI specification.
*/
public function build(): array
{
$config = config('api-docs', []);
if ($this->shouldCache($config)) {
$cacheKey = $config['cache']['key'] ?? 'api-docs:openapi';
$cacheTtl = $config['cache']['ttl'] ?? 3600;
return Cache::remember($cacheKey, $cacheTtl, fn () => $this->buildSpec($config));
}
return $this->buildSpec($config);
}
/**
* Clear the cached specification.
*/
public function clearCache(): void
{
$cacheKey = config('api-docs.cache.key', 'api-docs:openapi');
Cache::forget($cacheKey);
}
/**
* Check if caching should be enabled.
*/
protected function shouldCache(array $config): bool
{
if (! ($config['cache']['enabled'] ?? true)) {
return false;
}
$disabledEnvs = $config['cache']['disabled_environments'] ?? ['local', 'testing'];
return ! in_array(app()->environment(), $disabledEnvs, true);
}
/**
* Build the full OpenAPI specification.
*/
protected function buildSpec(array $config): array
{
$spec = [
'openapi' => '3.1.0',
'info' => $this->buildInfo($config),
'servers' => $this->buildServers($config),
'tags' => [],
'paths' => [],
'components' => $this->buildComponents($config),
];
// Build paths and collect tags
$spec['paths'] = $this->buildPaths($config);
$spec['tags'] = $this->buildTags($config);
// Apply extensions to spec
foreach ($this->extensions as $extension) {
$spec = $extension->extend($spec, $config);
}
return $spec;
}
/**
* Build API info section.
*/
protected function buildInfo(array $config): array
{
$info = $config['info'] ?? [];
$result = [
'title' => $info['title'] ?? config('app.name', 'API').' API',
'version' => $info['version'] ?? config('api.version', '1.0.0'),
];
if (! empty($info['description'])) {
$result['description'] = $info['description'];
}
if (! empty($info['contact'])) {
$contact = array_filter($info['contact']);
if (! empty($contact)) {
$result['contact'] = $contact;
}
}
if (! empty($info['license']['name'])) {
$result['license'] = array_filter($info['license']);
}
return $result;
}
/**
* Build servers section.
*/
protected function buildServers(array $config): array
{
$servers = $config['servers'] ?? [];
if (empty($servers)) {
return [
[
'url' => config('app.url', 'http://localhost'),
'description' => 'Current Environment',
],
];
}
return array_map(fn ($server) => array_filter($server), $servers);
}
/**
* Build tags section from discovered modules and config.
*/
protected function buildTags(array $config): array
{
$configTags = $config['tags'] ?? [];
$tags = [];
// Add discovered tags first
foreach ($this->discoveredTags as $name => $data) {
$tags[$name] = [
'name' => $name,
'description' => $data['description'] ?? null,
];
}
// Merge with configured tags (config takes precedence)
foreach ($configTags as $key => $tagConfig) {
$tagName = $tagConfig['name'] ?? $key;
$tags[$tagName] = [
'name' => $tagName,
'description' => $tagConfig['description'] ?? null,
];
}
// Clean up null descriptions and sort
$result = [];
foreach ($tags as $tag) {
$result[] = array_filter($tag);
}
usort($result, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $result;
}
/**
* Build paths section from routes.
*/
protected function buildPaths(array $config): array
{
$paths = [];
$includePatterns = $config['routes']['include'] ?? ['api/*'];
$excludePatterns = $config['routes']['exclude'] ?? [];
foreach (RouteFacade::getRoutes() as $route) {
/** @var Route $route */
if (! $this->shouldIncludeRoute($route, $includePatterns, $excludePatterns)) {
continue;
}
$path = $this->normalizePath($route->uri());
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
foreach ($methods as $method) {
$method = strtolower($method);
$operation = $this->buildOperation($route, $method, $config);
if ($operation !== null) {
$paths[$path][$method] = $operation;
}
}
}
ksort($paths);
return $paths;
}
/**
* Check if a route should be included in documentation.
*/
protected function shouldIncludeRoute(Route $route, array $include, array $exclude): bool
{
$uri = $route->uri();
// Check exclusions first
foreach ($exclude as $pattern) {
if (fnmatch($pattern, $uri)) {
return false;
}
}
// Check inclusions
foreach ($include as $pattern) {
if (fnmatch($pattern, $uri)) {
return true;
}
}
return false;
}
/**
* Normalize route path to OpenAPI format.
*/
protected function normalizePath(string $uri): string
{
// Prepend slash if missing
$path = '/'.ltrim($uri, '/');
// Convert Laravel parameters to OpenAPI format: {param?} -> {param}
$path = preg_replace('/\{([^}?]+)\?\}/', '{$1}', $path);
return $path === '/' ? '/' : rtrim($path, '/');
}
/**
* Build operation for a specific route and method.
*/
protected function buildOperation(Route $route, string $method, array $config): ?array
{
$controller = $route->getController();
$action = $route->getActionMethod();
// Check for ApiHidden attribute
if ($this->isHidden($controller, $action)) {
return null;
}
$operation = [
'summary' => $this->buildSummary($route, $method),
'operationId' => $this->buildOperationId($route, $method),
'tags' => $this->buildOperationTags($route, $controller, $action),
'responses' => $this->buildResponses($controller, $action),
];
// Add description from PHPDoc if available
$description = $this->extractDescription($controller, $action);
if ($description) {
$operation['description'] = $description;
}
// Add parameters
$parameters = $this->buildParameters($route, $controller, $action, $config);
if (! empty($parameters)) {
$operation['parameters'] = $parameters;
}
// Add request body for POST/PUT/PATCH
if (in_array($method, ['post', 'put', 'patch'])) {
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
}
// Add security requirements
$security = $this->buildSecurity($route, $controller, $action);
if ($security !== null) {
$operation['security'] = $security;
}
// Apply extensions to operation
foreach ($this->extensions as $extension) {
$operation = $extension->extendOperation($operation, $route, $method, $config);
}
return $operation;
}
/**
* Check if controller/method is hidden from docs.
*/
protected function isHidden(?object $controller, string $action): bool
{
if ($controller === null) {
return false;
}
$reflection = new ReflectionClass($controller);
// Check class-level attribute
$classAttrs = $reflection->getAttributes(ApiHidden::class);
if (! empty($classAttrs)) {
return true;
}
// Check method-level attribute
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$methodAttrs = $method->getAttributes(ApiHidden::class);
if (! empty($methodAttrs)) {
return true;
}
}
return false;
}
/**
* Build operation summary.
*/
protected function buildSummary(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
// Convert route name to human-readable summary
$parts = explode('.', $name);
$action = array_pop($parts);
return Str::title(str_replace(['-', '_'], ' ', $action));
}
// Generate from URI and method
$uri = Str::afterLast($route->uri(), '/');
return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri));
}
/**
* Build operation ID from route name.
*/
protected function buildOperationId(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
return Str::camel(str_replace(['.', '-'], '_', $name));
}
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
}
/**
* Build tags for an operation.
*/
protected function buildOperationTags(Route $route, ?object $controller, string $action): array
{
// Check for ApiTag attribute
if ($controller !== null) {
$tagAttr = $this->getAttribute($controller, $action, ApiTag::class);
if ($tagAttr !== null) {
$tag = $tagAttr->newInstance();
$this->discoveredTags[$tag->name] = ['description' => $tag->description];
return [$tag->name];
}
}
// Infer tag from route
return [$this->inferTag($route)];
}
/**
* Infer tag from route.
*/
protected function inferTag(Route $route): string
{
$uri = $route->uri();
$name = $route->getName() ?? '';
// Common tag mappings by route prefix
$tagMap = [
'api/bio' => 'Bio Links',
'api/blocks' => 'Bio Links',
'api/shortlinks' => 'Bio Links',
'api/qr' => 'Bio Links',
'api/commerce' => 'Commerce',
'api/provisioning' => 'Commerce',
'api/workspaces' => 'Workspaces',
'api/analytics' => 'Analytics',
'api/social' => 'Social',
'api/notify' => 'Notifications',
'api/support' => 'Support',
'api/pixel' => 'Pixel',
'api/seo' => 'SEO',
'api/mcp' => 'MCP',
'api/content' => 'Content',
'api/trust' => 'Trust',
'api/webhooks' => 'Webhooks',
'api/entitlements' => 'Entitlements',
];
foreach ($tagMap as $prefix => $tag) {
if (str_starts_with($uri, $prefix)) {
$this->discoveredTags[$tag] = $this->discoveredTags[$tag] ?? [];
return $tag;
}
}
$this->discoveredTags['General'] = $this->discoveredTags['General'] ?? [];
return 'General';
}
/**
* Extract description from PHPDoc.
*/
protected function extractDescription(?object $controller, string $action): ?string
{
if ($controller === null) {
return null;
}
$reflection = new ReflectionClass($controller);
if (! $reflection->hasMethod($action)) {
return null;
}
$method = $reflection->getMethod($action);
$doc = $method->getDocComment();
if (! $doc) {
return null;
}
// Extract description from PHPDoc (first paragraph before @tags)
preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n\s*\*\s*\n|\n\s*\*\s*@)/s', $doc, $matches);
if (! empty($matches[1])) {
$description = preg_replace('/\n\s*\*\s*/', ' ', $matches[1]);
return trim($description);
}
return null;
}
/**
* Build parameters for operation.
*/
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
{
$parameters = [];
// Add path parameters
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
foreach ($matches[1] as $param) {
$parameters[] = [
'name' => $param,
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
];
}
// Add parameters from ApiParameter attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$paramAttrs = $method->getAttributes(ApiParameter::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($paramAttrs as $attr) {
$param = $attr->newInstance();
$parameters[] = $param->toOpenApi();
}
}
}
return $parameters;
}
/**
* Build responses section.
*/
protected function buildResponses(?object $controller, string $action): array
{
$responses = [];
// Get ApiResponse attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$responseAttrs = $method->getAttributes(ApiResponse::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($responseAttrs as $attr) {
$response = $attr->newInstance();
$responses[(string) $response->status] = $this->buildResponseSchema($response);
}
}
}
// Default 200 response if none specified
if (empty($responses)) {
$responses['200'] = ['description' => 'Successful response'];
}
return $responses;
}
/**
* Build response schema from ApiResponse attribute.
*/
protected function buildResponseSchema(ApiResponse $response): array
{
$result = [
'description' => $response->getDescription(),
];
if ($response->resource !== null && class_exists($response->resource)) {
$schema = $this->extractResourceSchema($response->resource);
if ($response->paginated) {
$schema = $this->wrapPaginatedSchema($schema);
}
$result['content'] = [
'application/json' => [
'schema' => $schema,
],
];
}
if (! empty($response->headers)) {
$result['headers'] = [];
foreach ($response->headers as $header => $description) {
$result['headers'][$header] = [
'description' => $description,
'schema' => ['type' => 'string'],
];
}
}
return $result;
}
/**
* Extract schema from JsonResource class.
*/
protected function extractResourceSchema(string $resourceClass): array
{
if (! is_subclass_of($resourceClass, JsonResource::class)) {
return ['type' => 'object'];
}
// For now, return a generic object schema
// A more sophisticated implementation would analyze the resource's toArray method
return [
'type' => 'object',
'additionalProperties' => true,
];
}
/**
* Wrap schema in pagination structure.
*/
protected function wrapPaginatedSchema(array $itemSchema): array
{
return [
'type' => 'object',
'properties' => [
'data' => [
'type' => 'array',
'items' => $itemSchema,
],
'links' => [
'type' => 'object',
'properties' => [
'first' => ['type' => 'string', 'format' => 'uri'],
'last' => ['type' => 'string', 'format' => 'uri'],
'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
],
],
'meta' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
],
];
}
/**
* Build request body schema.
*/
protected function buildRequestBody(?object $controller, string $action): array
{
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => ['type' => 'object'],
],
],
];
}
/**
* Build security requirements.
*/
protected function buildSecurity(Route $route, ?object $controller, string $action): ?array
{
// Check for ApiSecurity attribute
if ($controller !== null) {
$securityAttr = $this->getAttribute($controller, $action, ApiSecurity::class);
if ($securityAttr !== null) {
$security = $securityAttr->newInstance();
if ($security->isPublic()) {
return []; // Empty array means no auth required
}
return [[$security->scheme => $security->scopes]];
}
}
// Infer from route middleware
$middleware = $route->middleware();
if (in_array('auth:sanctum', $middleware) || in_array('auth', $middleware)) {
return [['bearerAuth' => []]];
}
if (in_array('api.auth', $middleware) || in_array('auth.api', $middleware)) {
return [['apiKeyAuth' => []]];
}
foreach ($middleware as $m) {
if (str_contains($m, 'ApiKeyAuth') || str_contains($m, 'AuthenticateApiKey')) {
return [['apiKeyAuth' => []]];
}
}
return null;
}
/**
* Build components section.
*/
protected function buildComponents(array $config): array
{
$components = [
'securitySchemes' => [],
'schemas' => $this->buildCommonSchemas(),
];
// Add API Key security scheme
$apiKeyConfig = $config['auth']['api_key'] ?? [];
if ($apiKeyConfig['enabled'] ?? true) {
$components['securitySchemes']['apiKeyAuth'] = [
'type' => 'apiKey',
'in' => $apiKeyConfig['in'] ?? 'header',
'name' => $apiKeyConfig['name'] ?? 'X-API-Key',
'description' => $apiKeyConfig['description'] ?? 'API key for authentication',
];
}
// Add Bearer token security scheme
$bearerConfig = $config['auth']['bearer'] ?? [];
if ($bearerConfig['enabled'] ?? true) {
$components['securitySchemes']['bearerAuth'] = [
'type' => 'http',
'scheme' => $bearerConfig['scheme'] ?? 'bearer',
'bearerFormat' => $bearerConfig['format'] ?? 'JWT',
'description' => $bearerConfig['description'] ?? 'Bearer token authentication',
];
}
// Add OAuth2 security scheme
$oauth2Config = $config['auth']['oauth2'] ?? [];
if ($oauth2Config['enabled'] ?? false) {
$components['securitySchemes']['oauth2'] = [
'type' => 'oauth2',
'flows' => $oauth2Config['flows'] ?? [],
];
}
return $components;
}
/**
* Build common reusable schemas.
*/
protected function buildCommonSchemas(): array
{
return [
'Error' => [
'type' => 'object',
'required' => ['message'],
'properties' => [
'message' => ['type' => 'string', 'description' => 'Error message'],
'errors' => [
'type' => 'object',
'description' => 'Validation errors (field => messages)',
'additionalProperties' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
],
],
'Pagination' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
];
}
/**
* Get attribute from controller class or method.
*
* @template T
*
* @param class-string<T> $attributeClass
* @return ReflectionAttribute<T>|null
*/
protected function getAttribute(object $controller, string $action, string $attributeClass): ?ReflectionAttribute
{
$reflection = new ReflectionClass($controller);
// Check method first (method takes precedence)
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$attrs = $method->getAttributes($attributeClass);
if (! empty($attrs)) {
return $attrs[0];
}
}
// Fall back to class
$attrs = $reflection->getAttributes($attributeClass);
return $attrs[0] ?? null;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Core\Api\Documentation\DocumentationController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Documentation Routes
|--------------------------------------------------------------------------
|
| These routes serve the OpenAPI documentation and interactive API explorers.
| Protected by the ProtectDocumentation middleware for production environments.
|
*/
// Documentation UI routes
Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
// OpenAPI specification routes
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
->name('api.docs.openapi.json')
->middleware('throttle:60,1');
Route::get('/openapi.yaml', [DocumentationController::class, 'openApiYaml'])
->name('api.docs.openapi.yaml')
->middleware('throttle:60,1');
// Cache management (admin only)
Route::post('/cache/clear', [DocumentationController::class, 'clearCache'])
->name('api.docs.cache.clear')
->middleware('auth');

View file

@ -0,0 +1,60 @@
<!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 - ReDoc">
<title>{{ config('api-docs.info.title', 'API Documentation') }} - ReDoc</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Custom ReDoc theme overrides */
.redoc-wrap {
--primary-color: #3b82f6;
--primary-color-dark: #2563eb;
--selection-color: rgba(59, 130, 246, 0.1);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a2e;
}
}
</style>
</head>
<body>
<redoc spec-url="{{ $specUrl }}"
expand-responses="200,201"
path-in-middle-panel
hide-hostname
hide-download-button
required-props-first
sort-props-alphabetically
no-auto-auth
theme='{
"colors": {
"primary": { "main": "#3b82f6" }
},
"typography": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif",
"headings": { "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif" },
"code": { "fontFamily": "\"JetBrains Mono\", \"Fira Code\", Consolas, monospace" }
},
"sidebar": {
"width": "280px"
},
"rightPanel": {
"backgroundColor": "#1e293b"
}
}'>
</redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
<!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 - Scalar">
<title>{{ config('api-docs.info.title', 'API Documentation') }}</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script
id="api-reference"
data-url="{{ $specUrl }}"
data-configuration='{
"theme": "{{ $config['theme'] ?? 'default' }}",
"showSidebar": {{ ($config['show_sidebar'] ?? true) ? 'true' : 'false' }},
"hideDownloadButton": {{ ($config['hide_download_button'] ?? false) ? 'true' : 'false' }},
"hideModels": {{ ($config['hide_models'] ?? false) ? 'true' : 'false' }},
"darkMode": false,
"layout": "modern",
"searchHotKey": "k"
}'
></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

View file

@ -0,0 +1,65 @@
<!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 - Swagger UI">
<title>{{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
.swagger-ui .info { margin: 20px 0; }
.swagger-ui .info .title { font-size: 28px; }
.swagger-ui .scheme-container { background: transparent; box-shadow: none; padding: 0; }
.swagger-ui .opblock-tag { font-size: 18px; }
.swagger-ui .opblock .opblock-summary-operation-id { font-size: 13px; }
/* Dark mode support */
@media (prefers-color-scheme: dark) {
body { background: #1a1a2e; }
.swagger-ui { filter: invert(88%) hue-rotate(180deg); }
.swagger-ui .opblock-body pre { filter: invert(100%) hue-rotate(180deg); }
.swagger-ui img { filter: invert(100%) hue-rotate(180deg); }
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: @json($specUrl),
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "BaseLayout",
defaultModelsExpandDepth: -1,
docExpansion: @json($config['doc_expansion'] ?? 'none'),
filter: @json($config['filter'] ?? true),
showExtensions: @json($config['show_extensions'] ?? true),
showCommonExtensions: @json($config['show_common_extensions'] ?? true),
syntaxHighlight: {
activated: true,
theme: "monokai"
},
requestInterceptor: function(request) {
// Add any default headers here
return request;
}
});
};
</script>
</body>
</html>

View file

@ -0,0 +1,319 @@
<?php
/**
* API Documentation Configuration
*
* Configuration for OpenAPI/Swagger documentation powered by Scramble.
*/
return [
/*
|--------------------------------------------------------------------------
| Documentation Enabled
|--------------------------------------------------------------------------
|
| Enable or disable API documentation. When disabled, the /api/docs
| endpoint will return 404.
|
*/
'enabled' => env('API_DOCS_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Documentation Path
|--------------------------------------------------------------------------
|
| The URL path where API documentation is served.
|
*/
'path' => '/api/docs',
/*
|--------------------------------------------------------------------------
| API Information
|--------------------------------------------------------------------------
|
| Basic information about your API displayed in the documentation.
|
*/
'info' => [
'title' => env('API_DOCS_TITLE', 'API Documentation'),
'description' => env('API_DOCS_DESCRIPTION', 'REST API for programmatic access to services.'),
'version' => env('API_DOCS_VERSION', '1.0.0'),
'contact' => [
'name' => env('API_DOCS_CONTACT_NAME'),
'email' => env('API_DOCS_CONTACT_EMAIL'),
'url' => env('API_DOCS_CONTACT_URL'),
],
'license' => [
'name' => env('API_DOCS_LICENSE_NAME', 'Proprietary'),
'url' => env('API_DOCS_LICENSE_URL'),
],
],
/*
|--------------------------------------------------------------------------
| Servers
|--------------------------------------------------------------------------
|
| List of API servers displayed in the documentation.
|
*/
'servers' => [
[
'url' => env('APP_URL', 'http://localhost'),
'description' => 'Current Environment',
],
],
/*
|--------------------------------------------------------------------------
| Authentication Schemes
|--------------------------------------------------------------------------
|
| Configure how authentication is documented in OpenAPI.
|
*/
'auth' => [
// API Key authentication via header
'api_key' => [
'enabled' => true,
'name' => 'X-API-Key',
'in' => 'header',
'description' => 'API key for authentication. Create keys in your workspace settings.',
],
// Bearer token authentication
'bearer' => [
'enabled' => true,
'scheme' => 'bearer',
'format' => 'JWT',
'description' => 'Bearer token authentication for user sessions.',
],
// OAuth2 (if applicable)
'oauth2' => [
'enabled' => false,
'flows' => [
'authorizationCode' => [
'authorizationUrl' => '/oauth/authorize',
'tokenUrl' => '/oauth/token',
'refreshUrl' => '/oauth/token',
'scopes' => [
'read' => 'Read access to resources',
'write' => 'Write access to resources',
'delete' => 'Delete access to resources',
],
],
],
],
],
/*
|--------------------------------------------------------------------------
| Workspace Header
|--------------------------------------------------------------------------
|
| Configure the workspace header documentation.
|
*/
'workspace' => [
'header_name' => 'X-Workspace-ID',
'required' => false,
'description' => 'Optional workspace identifier for multi-tenant operations. If not provided, the default workspace associated with the API key will be used.',
],
/*
|--------------------------------------------------------------------------
| Rate Limiting Documentation
|--------------------------------------------------------------------------
|
| Configure how rate limits are documented in responses.
|
*/
'rate_limits' => [
'enabled' => true,
'headers' => [
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
'Retry-After' => 'Seconds to wait before retrying (only on 429 responses)',
],
],
/*
|--------------------------------------------------------------------------
| Module Tags
|--------------------------------------------------------------------------
|
| Map module namespaces to documentation tags for grouping endpoints.
|
*/
'tags' => [
// Module namespace => Tag configuration
'Bio' => [
'name' => 'Bio Links',
'description' => 'Bio link pages, blocks, and customization',
],
'Commerce' => [
'name' => 'Commerce',
'description' => 'Billing, subscriptions, orders, and invoices',
],
'Analytics' => [
'name' => 'Analytics',
'description' => 'Website and link analytics tracking',
],
'Social' => [
'name' => 'Social',
'description' => 'Social media management and scheduling',
],
'Notify' => [
'name' => 'Notifications',
'description' => 'Push notifications and alerts',
],
'Support' => [
'name' => 'Support',
'description' => 'Helpdesk and customer support',
],
'Tenant' => [
'name' => 'Workspaces',
'description' => 'Workspace and team management',
],
'Pixel' => [
'name' => 'Pixel',
'description' => 'Unified tracking pixel endpoints',
],
'SEO' => [
'name' => 'SEO',
'description' => 'SEO analysis and reporting',
],
'MCP' => [
'name' => 'MCP',
'description' => 'Model Context Protocol HTTP bridge',
],
'Content' => [
'name' => 'Content',
'description' => 'AI content generation',
],
'Trust' => [
'name' => 'Trust',
'description' => 'Social proof and testimonials',
],
'Webhooks' => [
'name' => 'Webhooks',
'description' => 'Webhook endpoints and management',
],
],
/*
|--------------------------------------------------------------------------
| Route Filtering
|--------------------------------------------------------------------------
|
| Configure which routes are included in the documentation.
|
*/
'routes' => [
// Only include routes matching these patterns
'include' => [
'api/*',
],
// Exclude routes matching these patterns
'exclude' => [
'api/sanctum/*',
'api/telescope/*',
'api/horizon/*',
],
// Hide internal/admin routes from public docs
'hide_internal' => true,
],
/*
|--------------------------------------------------------------------------
| Documentation UI
|--------------------------------------------------------------------------
|
| Configure the documentation UI appearance.
|
*/
'ui' => [
// Default UI renderer: 'swagger', 'scalar', 'redoc', 'stoplight'
'default' => 'scalar',
// Swagger UI specific options
'swagger' => [
'doc_expansion' => 'none', // 'list', 'full', 'none'
'filter' => true,
'show_extensions' => true,
'show_common_extensions' => true,
],
// Scalar specific options
'scalar' => [
'theme' => 'default', // 'default', 'alternate', 'moon', 'purple', 'solarized'
'show_sidebar' => true,
'hide_download_button' => false,
'hide_models' => false,
],
],
/*
|--------------------------------------------------------------------------
| Access Control
|--------------------------------------------------------------------------
|
| Configure who can access the documentation.
|
*/
'access' => [
// Require authentication to view docs
'require_auth' => env('API_DOCS_REQUIRE_AUTH', false),
// Only allow these roles to view docs (empty = all authenticated users)
'allowed_roles' => [],
// Allow unauthenticated access in these environments
'public_environments' => ['local', 'testing', 'staging'],
// IP whitelist for production (empty = no restriction)
'ip_whitelist' => [],
],
/*
|--------------------------------------------------------------------------
| Caching
|--------------------------------------------------------------------------
|
| Configure documentation caching.
|
*/
'cache' => [
// Enable caching of generated OpenAPI spec
'enabled' => env('API_DOCS_CACHE_ENABLED', true),
// Cache key prefix
'key' => 'api-docs:openapi',
// Cache duration in seconds (1 hour default)
'ttl' => env('API_DOCS_CACHE_TTL', 3600),
// Disable cache in these environments
'disabled_environments' => ['local', 'testing'],
],
];

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Core\Api\Enums;
/**
* Built-in webhook template types.
*
* Pre-defined template configurations for common webhook destinations.
*/
enum BuiltinTemplateType: string
{
/**
* Full event data - sends everything.
*/
case FULL = 'full';
/**
* Minimal payload - essential fields only.
*/
case MINIMAL = 'minimal';
/**
* Slack-formatted message.
*/
case SLACK = 'slack';
/**
* Discord-formatted message.
*/
case DISCORD = 'discord';
/**
* Get human-readable label for the type.
*/
public function label(): string
{
return match ($this) {
self::FULL => 'Full payload',
self::MINIMAL => 'Minimal payload',
self::SLACK => 'Slack message',
self::DISCORD => 'Discord message',
};
}
/**
* Get description for the type.
*/
public function description(): string
{
return match ($this) {
self::FULL => 'Sends all event data in a structured format.',
self::MINIMAL => 'Sends only essential fields: event type, ID, and timestamp.',
self::SLACK => 'Formats payload for Slack incoming webhooks with blocks.',
self::DISCORD => 'Formats payload for Discord webhooks with embeds.',
};
}
/**
* Get the default template content for this type.
*/
public function template(): string
{
return match ($this) {
self::FULL => <<<'JSON'
{
"event": "{{event.type}}",
"timestamp": "{{timestamp}}",
"timestamp_unix": {{timestamp_unix}},
"data": {{data | json}}
}
JSON,
self::MINIMAL => <<<'JSON'
{
"event": "{{event.type}}",
"id": "{{data.id}}",
"timestamp": "{{timestamp}}"
}
JSON,
self::SLACK => <<<'JSON'
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "{{event.name}}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{message}}"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "*Event:* `{{event.type}}` | *Time:* {{timestamp | iso8601}}"
}
]
}
]
}
JSON,
self::DISCORD => <<<'JSON'
{
"embeds": [
{
"title": "{{event.name}}",
"description": "{{message}}",
"color": 5814783,
"fields": [
{
"name": "Event Type",
"value": "`{{event.type}}`",
"inline": true
},
{
"name": "ID",
"value": "{{data.id | default:N/A}}",
"inline": true
}
],
"timestamp": "{{timestamp}}"
}
]
}
JSON,
};
}
/**
* Get the template format for this type.
*/
public function format(): WebhookTemplateFormat
{
return WebhookTemplateFormat::JSON;
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Core\Api\Enums;
/**
* Webhook payload template formats.
*
* Defines supported template syntaxes for customising webhook payloads.
*/
enum WebhookTemplateFormat: string
{
/**
* Simple variable substitution: {{variable.path}}
*/
case SIMPLE = 'simple';
/**
* Mustache-style templates with conditionals and loops.
*/
case MUSTACHE = 'mustache';
/**
* Raw JSON with variable interpolation.
*/
case JSON = 'json';
/**
* Get human-readable label for the format.
*/
public function label(): string
{
return match ($this) {
self::SIMPLE => 'Simple (variable substitution)',
self::MUSTACHE => 'Mustache (conditionals and loops)',
self::JSON => 'JSON (structured template)',
};
}
/**
* Get description for the format.
*/
public function description(): string
{
return match ($this) {
self::SIMPLE => 'Basic {{variable}} replacement. Best for simple payloads.',
self::MUSTACHE => 'Full Mustache syntax with {{#if}}, {{#each}}, and filters.',
self::JSON => 'JSON template with embedded {{variables}}. Validates structure.',
};
}
/**
* Get example template for the format.
*/
public function example(): string
{
return match ($this) {
self::SIMPLE => '{"event": "{{event.type}}", "id": "{{data.id}}"}',
self::MUSTACHE => '{"event": "{{event.type}}"{{#if data.user}}, "user": "{{data.user.name}}"{{/if}}}',
self::JSON => <<<'JSON'
{
"event": "{{event.type}}",
"timestamp": "{{timestamp | iso8601}}",
"data": {
"id": "{{data.id}}",
"name": "{{data.name}}"
}
}
JSON,
};
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Api\Exceptions;
use Core\Api\RateLimit\RateLimitResult;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Exception thrown when API rate limit is exceeded.
*
* Renders as a proper JSON response with rate limit headers.
*/
class RateLimitExceededException extends HttpException
{
public function __construct(
protected RateLimitResult $rateLimitResult,
string $message = 'Too many requests. Please slow down.',
) {
parent::__construct(429, $message);
}
/**
* Get the rate limit result.
*/
public function getRateLimitResult(): RateLimitResult
{
return $this->rateLimitResult;
}
/**
* Render the exception as a JSON response.
*/
public function render(): JsonResponse
{
return response()->json([
'error' => 'rate_limit_exceeded',
'message' => $this->getMessage(),
'retry_after' => $this->rateLimitResult->retryAfter,
'limit' => $this->rateLimitResult->limit,
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
], 429, $this->rateLimitResult->headers());
}
/**
* Get headers for the response.
*
* @return array<string, string|int>
*/
public function getHeaders(): array
{
return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers());
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Core\Api\Guards;
use Core\Tenant\Models\User;
use Core\Tenant\Models\UserToken;
use Illuminate\Contracts\Auth\Factory;
use Illuminate\Http\Request;
/**
* Custom authentication guard for API token-based authentication.
*
* This guard authenticates users via Bearer tokens sent in the Authorization header.
* It's designed to work with Laravel's auth middleware system and provides
* stateful API authentication using long-lived personal access tokens.
*
* Usage:
* Route::middleware('auth:access_token')->group(function () {
* // Protected API routes
* });
*/
class AccessTokenGuard
{
/**
* The authentication factory instance.
*/
protected Factory $auth;
/**
* Create a new guard instance.
*/
public function __construct(Factory $auth)
{
$this->auth = $auth;
}
/**
* Handle the authentication for the incoming request.
*
* This method is called by Laravel's authentication system when using
* the guard. It attempts to authenticate the request using the Bearer
* token and returns the authenticated user if successful.
*
* @return User|null The authenticated user or null if authentication fails
*/
public function __invoke(Request $request): ?User
{
$token = $this->getTokenFromRequest($request);
if (! $token) {
return null;
}
$accessToken = UserToken::findToken($token);
if (! $this->isValidAccessToken($accessToken)) {
return null;
}
// Update last used timestamp
$accessToken->recordUsage();
return $accessToken->user;
}
/**
* Extract the Bearer token from the request.
*
* Looks for the token in the Authorization header in the format:
* Authorization: Bearer {token}
*
* @return string|null The extracted token or null if not found
*/
protected function getTokenFromRequest(Request $request): ?string
{
$token = $request->bearerToken();
return ! empty($token) ? $token : null;
}
/**
* Validate the access token.
*
* Checks if the token exists and hasn't expired.
*
* @return bool True if the token is valid, false otherwise
*/
protected function isValidAccessToken(?UserToken $accessToken): bool
{
if (! $accessToken) {
return false;
}
return $accessToken->isValid();
}
}

View file

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Core\Api\Jobs;
use Core\Api\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Delivers webhook payloads to registered endpoints.
*
* Implements exponential backoff retry logic:
* - Attempt 1: Immediate
* - Attempt 2: 1 minute delay
* - Attempt 3: 5 minutes delay
* - Attempt 4: 30 minutes delay
* - Attempt 5: 2 hours delay
* - Attempt 6 (final): 24 hours delay
*/
class DeliverWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
* We handle retries manually with exponential backoff.
*/
public int $tries = 1;
/**
* Create a new job instance.
*/
public function __construct(
public WebhookDelivery $delivery
) {
// Use dedicated webhook queue if configured
$this->queue = config('api.webhooks.queue', 'default');
$connection = config('api.webhooks.queue_connection');
if ($connection) {
$this->connection = $connection;
}
}
/**
* Execute the job.
*/
public function handle(): void
{
// Don't deliver if endpoint is disabled
$endpoint = $this->delivery->endpoint;
if (! $endpoint || ! $endpoint->shouldReceive($this->delivery->event_type)) {
Log::info('Webhook delivery skipped - endpoint inactive or does not receive this event', [
'delivery_id' => $this->delivery->id,
'event_type' => $this->delivery->event_type,
]);
return;
}
// Get delivery payload with signature headers
$deliveryPayload = $this->delivery->getDeliveryPayload();
$timeout = config('api.webhooks.timeout', 30);
Log::info('Attempting webhook delivery', [
'delivery_id' => $this->delivery->id,
'endpoint_url' => $endpoint->url,
'event_type' => $this->delivery->event_type,
'attempt' => $this->delivery->attempt,
]);
try {
$response = Http::timeout($timeout)
->withHeaders($deliveryPayload['headers'])
->withBody($deliveryPayload['body'], 'application/json')
->post($endpoint->url);
$statusCode = $response->status();
$responseBody = $response->body();
// Success is any 2xx status code
if ($response->successful()) {
$this->delivery->markSuccess($statusCode, $responseBody);
Log::info('Webhook delivered successfully', [
'delivery_id' => $this->delivery->id,
'status_code' => $statusCode,
]);
return;
}
// Non-2xx response - mark as failed and potentially retry
$this->handleFailure($statusCode, $responseBody);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
// Connection timeout or refused
$this->handleFailure(0, 'Connection failed: '.$e->getMessage());
} catch (\Throwable $e) {
// Unexpected error
$this->handleFailure(0, 'Unexpected error: '.$e->getMessage());
Log::error('Webhook delivery unexpected error', [
'delivery_id' => $this->delivery->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Handle a failed delivery attempt.
*/
protected function handleFailure(int $statusCode, ?string $responseBody): void
{
Log::warning('Webhook delivery failed', [
'delivery_id' => $this->delivery->id,
'attempt' => $this->delivery->attempt,
'status_code' => $statusCode,
'can_retry' => $this->delivery->canRetry(),
]);
// Mark as failed (this also schedules retry if attempts remain)
$this->delivery->markFailed($statusCode, $responseBody);
// If we can retry, dispatch a new job with the appropriate delay
if ($this->delivery->canRetry() && $this->delivery->next_retry_at) {
$delay = $this->delivery->next_retry_at->diffInSeconds(now());
Log::info('Scheduling webhook retry', [
'delivery_id' => $this->delivery->id,
'next_attempt' => $this->delivery->attempt,
'delay_seconds' => $delay,
'next_retry_at' => $this->delivery->next_retry_at->toIso8601String(),
]);
// Dispatch retry with calculated delay
self::dispatch($this->delivery->fresh())->delay($delay);
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Webhook delivery job failed completely', [
'delivery_id' => $this->delivery->id,
'error' => $exception->getMessage(),
]);
}
/**
* Get the tags for the job.
*
* @return array<string>
*/
public function tags(): array
{
return [
'webhook',
'webhook:'.$this->delivery->webhook_endpoint_id,
'event:'.$this->delivery->event_type,
];
}
}

View file

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Core\Api\Middleware;
use Core\Api\Models\ApiKey;
use Core\Api\Services\IpRestrictionService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Authenticate requests using API keys or fall back to Sanctum.
*
* API keys are prefixed with 'hk_' and scoped to a workspace.
*
* Register in bootstrap/app.php:
* ->withMiddleware(function (Middleware $middleware) {
* $middleware->alias([
* 'auth.api' => \App\Http\Middleware\Api\AuthenticateApiKey::class,
* ]);
* })
*/
class AuthenticateApiKey
{
public function handle(Request $request, Closure $next, ?string $scope = null): Response
{
$token = $request->bearerToken();
if (! $token) {
return $this->unauthorized('API key required. Use Authorization: Bearer <api_key>');
}
// Check if it's an API key (prefixed with hk_)
if (str_starts_with($token, 'hk_')) {
return $this->authenticateApiKey($request, $next, $token, $scope);
}
// Fall back to Sanctum for OAuth tokens
return $this->authenticateSanctum($request, $next, $scope);
}
/**
* Authenticate using an API key.
*/
protected function authenticateApiKey(
Request $request,
Closure $next,
string $token,
?string $scope
): Response {
$apiKey = ApiKey::findByPlainKey($token);
if (! $apiKey) {
return $this->unauthorized('Invalid API key');
}
if ($apiKey->isExpired()) {
return $this->unauthorized('API key has expired');
}
// Check IP whitelist if restrictions are enabled
if ($apiKey->hasIpRestrictions()) {
$ipService = app(IpRestrictionService::class);
$requestIp = $request->ip();
if (! $ipService->isIpAllowed($requestIp, $apiKey->getAllowedIps() ?? [])) {
return $this->forbidden('IP address not allowed for this API key');
}
}
// Check scope if required
if ($scope !== null && ! $apiKey->hasScope($scope)) {
return $this->forbidden("API key missing required scope: {$scope}");
}
// Record usage (non-blocking)
$apiKey->recordUsage();
// Set request context
$request->setUserResolver(fn () => $apiKey->user);
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('workspace', $apiKey->workspace);
$request->attributes->set('workspace_id', $apiKey->workspace_id);
$request->attributes->set('auth_type', 'api_key');
return $next($request);
}
/**
* Fall back to Sanctum authentication for OAuth tokens.
*/
protected function authenticateSanctum(
Request $request,
Closure $next,
?string $scope
): Response {
// For API requests, use token authentication
if (! $request->user()) {
// Try to authenticate via Sanctum token
$guard = auth('sanctum');
if (! $guard->check()) {
return $this->unauthorized('Invalid authentication token');
}
$request->setUserResolver(fn () => $guard->user());
}
$request->attributes->set('auth_type', 'sanctum');
return $next($request);
}
/**
* Return 401 Unauthorized response.
*/
protected function unauthorized(string $message): Response
{
return response()->json([
'error' => 'unauthorized',
'message' => $message,
], 401);
}
/**
* Return 403 Forbidden response.
*/
protected function forbidden(string $message): Response
{
return response()->json([
'error' => 'forbidden',
'message' => $message,
], 403);
}
}

Some files were not shown because too many files have changed in this diff Show more