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:
commit
753812ad57
191 changed files with 35312 additions and 0 deletions
24
.core/build.yaml
Normal file
24
.core/build.yaml
Normal 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
20
.core/release.yaml
Normal 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
7
.gitattributes
vendored
Normal 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
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Binaries
|
||||
core-api
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
|
||||
# PHP
|
||||
/vendor/
|
||||
node_modules/
|
||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal 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
287
LICENCE
Normal 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
191
api.go
Normal 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
204
api_test.go
Normal 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
228
authentik.go
Normal 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()
|
||||
}
|
||||
}
|
||||
337
authentik_integration_test.go
Normal file
337
authentik_integration_test.go
Normal 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
460
authentik_test.go
Normal 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
222
authz_test.go
Normal 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
122
bridge.go
Normal 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
234
bridge_test.go
Normal 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
120
brotli.go
Normal 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
132
brotli_test.go
Normal 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
126
cache.go
Normal 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
252
cache_test.go
Normal 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
18
cmd/api/cmd.go
Normal 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
89
cmd/api/cmd_sdk.go
Normal 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
54
cmd/api/cmd_spec.go
Normal 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
101
cmd/api/cmd_test.go
Normal 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
101
codegen.go
Normal 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
94
codegen_test.go
Normal 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
39
composer.json
Normal 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
617
docs/architecture.md
Normal 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
451
docs/development.md
Normal 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
219
docs/history.md
Normal 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
173
docs/index.md
Normal 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
56
export.go
Normal 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
166
export_test.go
Normal 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
141
expvar_test.go
Normal 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
124
go.mod
Normal 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
341
go.sum
Normal 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
63
graphql.go
Normal 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
234
graphql_test.go
Normal 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
44
group.go
Normal 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
226
group_test.go
Normal 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
133
gzip_test.go
Normal 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
216
httpsign_test.go
Normal 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
121
i18n.go
Normal 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
226
i18n_test.go
Normal 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
180
location_test.go
Normal 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
59
middleware.go
Normal 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
220
middleware_test.go
Normal 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
93
modernization_test.go
Normal 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
6
norace_test.go
Normal 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
184
openapi.go
Normal 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
403
openapi_test.go
Normal 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
325
options.go
Normal 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
52
pkg/provider/provider.go
Normal 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
12
pkg/provider/proxy.go
Normal 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
146
pkg/provider/registry.go
Normal 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
|
||||
}
|
||||
160
pkg/provider/registry_test.go
Normal file
160
pkg/provider/registry_test.go
Normal 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
124
pprof_test.go
Normal 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
6
race_test.go
Normal 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
71
response.go
Normal 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
205
response_test.go
Normal 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
185
secure_test.go
Normal 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
198
sessions_test.go
Normal 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
167
slog_test.go
Normal 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
33
src/php/phpunit.xml
Normal 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
146
src/php/src/Api/Boot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
src/php/src/Api/Concerns/HasApiResponses.php
Normal file
124
src/php/src/Api/Concerns/HasApiResponses.php
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/php/src/Api/Concerns/HasApiTokens.php
Normal file
76
src/php/src/Api/Concerns/HasApiTokens.php
Normal 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();
|
||||
}
|
||||
}
|
||||
84
src/php/src/Api/Concerns/ResolvesWorkspace.php
Normal file
84
src/php/src/Api/Concerns/ResolvesWorkspace.php
Normal 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';
|
||||
}
|
||||
}
|
||||
291
src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php
Normal file
291
src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
141
src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php
Normal file
141
src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/php/src/Api/Contracts/WebhookEvent.php
Normal file
35
src/php/src/Api/Contracts/WebhookEvent.php
Normal 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;
|
||||
}
|
||||
268
src/php/src/Api/Controllers/Api/WebhookSecretController.php
Normal file
268
src/php/src/Api/Controllers/Api/WebhookSecretController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
369
src/php/src/Api/Controllers/Api/WebhookTemplateController.php
Normal file
369
src/php/src/Api/Controllers/Api/WebhookTemplateController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
625
src/php/src/Api/Controllers/McpApiController.php
Normal file
625
src/php/src/Api/Controllers/McpApiController.php
Normal 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'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
266
src/php/src/Api/Database/Factories/ApiKeyFactory.php
Normal file
266
src/php/src/Api/Database/Factories/ApiKeyFactory.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
src/php/src/Api/Documentation/Attributes/ApiHidden.php
Normal file
41
src/php/src/Api/Documentation/Attributes/ApiHidden.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
101
src/php/src/Api/Documentation/Attributes/ApiParameter.php
Normal file
101
src/php/src/Api/Documentation/Attributes/ApiParameter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/php/src/Api/Documentation/Attributes/ApiResponse.php
Normal file
80
src/php/src/Api/Documentation/Attributes/ApiResponse.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
51
src/php/src/Api/Documentation/Attributes/ApiSecurity.php
Normal file
51
src/php/src/Api/Documentation/Attributes/ApiSecurity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/php/src/Api/Documentation/Attributes/ApiTag.php
Normal file
38
src/php/src/Api/Documentation/Attributes/ApiTag.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
128
src/php/src/Api/Documentation/DocumentationController.php
Normal file
128
src/php/src/Api/Documentation/DocumentationController.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
278
src/php/src/Api/Documentation/Examples/CommonExamples.php
Normal file
278
src/php/src/Api/Documentation/Examples/CommonExamples.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/php/src/Api/Documentation/Extension.php
Normal file
40
src/php/src/Api/Documentation/Extension.php
Normal 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;
|
||||
}
|
||||
234
src/php/src/Api/Documentation/Extensions/ApiKeyAuthExtension.php
Normal file
234
src/php/src/Api/Documentation/Extensions/ApiKeyAuthExtension.php
Normal 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);
|
||||
}
|
||||
}
|
||||
228
src/php/src/Api/Documentation/Extensions/RateLimitExtension.php
Normal file
228
src/php/src/Api/Documentation/Extensions/RateLimitExtension.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
209
src/php/src/Api/Documentation/ModuleDiscovery.php
Normal file
209
src/php/src/Api/Documentation/ModuleDiscovery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
819
src/php/src/Api/Documentation/OpenApiBuilder.php
Normal file
819
src/php/src/Api/Documentation/OpenApiBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/php/src/Api/Documentation/Routes/docs.php
Normal file
36
src/php/src/Api/Documentation/Routes/docs.php
Normal 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');
|
||||
60
src/php/src/Api/Documentation/Views/redoc.blade.php
Normal file
60
src/php/src/Api/Documentation/Views/redoc.blade.php
Normal 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>
|
||||
28
src/php/src/Api/Documentation/Views/scalar.blade.php
Normal file
28
src/php/src/Api/Documentation/Views/scalar.blade.php
Normal 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>
|
||||
65
src/php/src/Api/Documentation/Views/swagger.blade.php
Normal file
65
src/php/src/Api/Documentation/Views/swagger.blade.php
Normal 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>
|
||||
319
src/php/src/Api/Documentation/config.php
Normal file
319
src/php/src/Api/Documentation/config.php
Normal 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'],
|
||||
],
|
||||
|
||||
];
|
||||
144
src/php/src/Api/Enums/BuiltinTemplateType.php
Normal file
144
src/php/src/Api/Enums/BuiltinTemplateType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/php/src/Api/Enums/WebhookTemplateFormat.php
Normal file
73
src/php/src/Api/Enums/WebhookTemplateFormat.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/php/src/Api/Exceptions/RateLimitExceededException.php
Normal file
56
src/php/src/Api/Exceptions/RateLimitExceededException.php
Normal 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());
|
||||
}
|
||||
}
|
||||
98
src/php/src/Api/Guards/AccessTokenGuard.php
Normal file
98
src/php/src/Api/Guards/AccessTokenGuard.php
Normal 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();
|
||||
}
|
||||
}
|
||||
182
src/php/src/Api/Jobs/DeliverWebhookJob.php
Normal file
182
src/php/src/Api/Jobs/DeliverWebhookJob.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
136
src/php/src/Api/Middleware/AuthenticateApiKey.php
Normal file
136
src/php/src/Api/Middleware/AuthenticateApiKey.php
Normal 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
Loading…
Add table
Reference in a new issue