feat(api): webhooks + sunset headers + WithWebSocket + cmd/api migration
- webhook.go: HMAC-SHA256 WebhookSigner matching PHP WebhookSignature — sign/verify, X-Webhook-Signature / X-Webhook-Timestamp headers, VerifyRequest middleware helper, 5-minute default tolerance, secret generator (RFC §6) - sunset.go: ApiSunsetWith(date, replacement, opts...) + WithSunsetNoticeURL; ApiSunset now emits API-Suggested-Replacement when replacement set; RouteDescription.NoticeURL surfaces API-Deprecation-Notice-URL (RFC §8) - options.go + api.go + transport.go: WithWebSocket(gin.HandlerFunc) alongside existing WithWSHandler(http.Handler); gin form wins when both supplied (RFC §2.2) - openapi.go: apiSuggestedReplacement + apiDeprecationNoticeURL as reusable header components; NoticeURL on a RouteDescription flips operation deprecated flag and emits response header doc - cmd/api/*.go: migrated from Cobra (cli.NewCommand, StringFlag) to new path-based CLI API (c.Command + core.Options.String/Int/Bool); replaces the 1,422-line Cobra test suite with _Good/_Bad/_Ugly triads on the new surface - webhook_test.go + sunset_test.go + websocket_test.go: full coverage Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
fbb58486c4
commit
da1839f730
16 changed files with 1175 additions and 1628 deletions
10
api.go
10
api.go
|
|
@ -44,6 +44,7 @@ type Engine struct {
|
|||
cacheMaxEntries int
|
||||
cacheMaxBytes int
|
||||
wsHandler http.Handler
|
||||
wsGinHandler gin.HandlerFunc
|
||||
wsPath string
|
||||
sseBroker *SSEBroker
|
||||
swaggerEnabled bool
|
||||
|
|
@ -262,8 +263,13 @@ func (e *Engine) build() *gin.Engine {
|
|||
g.RegisterRoutes(rg)
|
||||
}
|
||||
|
||||
// Mount WebSocket handler if configured.
|
||||
if e.wsHandler != nil {
|
||||
// Mount WebSocket handler if configured. WithWebSocket (gin-native) takes
|
||||
// precedence over WithWSHandler (http.Handler) when both are supplied so
|
||||
// the more specific gin form wins.
|
||||
switch {
|
||||
case e.wsGinHandler != nil:
|
||||
r.GET(resolveWSPath(e.wsPath), e.wsGinHandler)
|
||||
case e.wsHandler != nil:
|
||||
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
// Package api registers the `core api` command group on the root Core
|
||||
// instance. It exposes two subcommands:
|
||||
//
|
||||
// - api/spec — generate the OpenAPI specification from registered route
|
||||
// groups plus the built-in tool bridge and write it to stdout or a file.
|
||||
// - api/sdk — run openapi-generator-cli over a generated spec to produce
|
||||
// client SDKs in the configured target languages.
|
||||
//
|
||||
// The commands use the Core framework's declarative Command API. Flags are
|
||||
// declared via the Flags Options map and read from the incoming Options
|
||||
// during the Action.
|
||||
package api
|
||||
|
||||
import "dappco.re/go/core/cli/pkg/cli"
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddAPICommands)
|
||||
}
|
||||
|
||||
// AddAPICommands registers the `api` command group.
|
||||
// AddAPICommands registers the `api/spec` and `api/sdk` commands on the given
|
||||
// Core instance.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// root := cli.NewGroup("root", "", "")
|
||||
// api.AddAPICommands(root)
|
||||
func AddAPICommands(root *cli.Command) {
|
||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||
root.AddCommand(apiCmd)
|
||||
|
||||
addSpecCommand(apiCmd)
|
||||
addSDKCommand(apiCmd)
|
||||
// core.RegisterCommands(api.AddAPICommands)
|
||||
func AddAPICommands(c *core.Core) {
|
||||
addSpecCommand(c)
|
||||
addSDKCommand(c)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,14 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/cli/pkg/cli"
|
||||
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
goapi "dappco.re/go/core/api"
|
||||
coreio "dappco.re/go/core/io"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -22,124 +19,95 @@ const (
|
|||
defaultSDKVersion = "1.0.0"
|
||||
)
|
||||
|
||||
func addSDKCommand(parent *cli.Command) {
|
||||
var (
|
||||
lang string
|
||||
output string
|
||||
specFile string
|
||||
packageName string
|
||||
cfg specBuilderConfig
|
||||
)
|
||||
|
||||
cfg.title = defaultSDKTitle
|
||||
cfg.description = defaultSDKDescription
|
||||
cfg.version = defaultSDKVersion
|
||||
|
||||
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
||||
languages := splitUniqueCSV(lang)
|
||||
if len(languages) == 0 {
|
||||
return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
|
||||
}
|
||||
|
||||
gen := &goapi.SDKGenerator{
|
||||
OutputDir: output,
|
||||
PackageName: packageName,
|
||||
}
|
||||
|
||||
if !gen.Available() {
|
||||
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
|
||||
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
|
||||
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
|
||||
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
|
||||
}
|
||||
|
||||
// If no spec file was provided, generate one only after confirming the
|
||||
// generator is available.
|
||||
resolvedSpecFile := specFile
|
||||
if resolvedSpecFile == "" {
|
||||
builder, err := sdkSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := sdkSpecGroupsIter()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
||||
if err != nil {
|
||||
return coreerr.E("sdk.Generate", "create temp spec file", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = coreio.Local.Delete(tmpPath)
|
||||
return coreerr.E("sdk.Generate", "close temp spec file", err)
|
||||
}
|
||||
defer coreio.Local.Delete(tmpPath)
|
||||
|
||||
if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil {
|
||||
return coreerr.E("sdk.Generate", "generate spec", err)
|
||||
}
|
||||
resolvedSpecFile = tmpPath
|
||||
}
|
||||
|
||||
gen.SpecPath = resolvedSpecFile
|
||||
|
||||
// Generate for each language.
|
||||
for _, l := range languages {
|
||||
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
|
||||
if err := gen.Generate(cli.Context(), l); err != nil {
|
||||
return coreerr.E("sdk.Generate", "generate "+l, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
||||
}
|
||||
|
||||
return nil
|
||||
func addSDKCommand(c *core.Core) {
|
||||
c.Command("api/sdk", core.Command{
|
||||
Description: "Generate client SDKs from OpenAPI spec",
|
||||
Action: sdkAction,
|
||||
})
|
||||
}
|
||||
|
||||
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
|
||||
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
||||
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to an existing OpenAPI spec (generates a temporary spec from registered route groups and the built-in tool bridge if not provided)")
|
||||
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
|
||||
registerSpecBuilderFlags(cmd, &cfg)
|
||||
func sdkAction(opts core.Options) core.Result {
|
||||
lang := opts.String("lang")
|
||||
output := opts.String("output")
|
||||
if output == "" {
|
||||
output = "./sdk"
|
||||
}
|
||||
specFile := opts.String("spec")
|
||||
packageName := opts.String("package")
|
||||
if packageName == "" {
|
||||
packageName = "lethean"
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
languages := splitUniqueCSV(lang)
|
||||
if len(languages) == 0 {
|
||||
return core.Result{Value: cli.Err("--lang is required and must include at least one non-empty language"), OK: false}
|
||||
}
|
||||
|
||||
gen := &goapi.SDKGenerator{
|
||||
OutputDir: output,
|
||||
PackageName: packageName,
|
||||
}
|
||||
|
||||
if !gen.Available() {
|
||||
cli.Error("openapi-generator-cli not found. Install with:")
|
||||
cli.Print(" brew install openapi-generator (macOS)")
|
||||
cli.Print(" npm install @openapitools/openapi-generator-cli -g")
|
||||
return core.Result{Value: cli.Err("openapi-generator-cli not installed"), OK: false}
|
||||
}
|
||||
|
||||
resolvedSpecFile := specFile
|
||||
if resolvedSpecFile == "" {
|
||||
cfg := sdkConfigFromOptions(opts)
|
||||
builder, err := sdkSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return core.Result{Value: err, OK: false}
|
||||
}
|
||||
groups := sdkSpecGroupsIter()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
||||
if err != nil {
|
||||
return core.Result{Value: cli.Wrap(err, "create temp spec file"), OK: false}
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = coreio.Local.Delete(tmpPath)
|
||||
return core.Result{Value: cli.Wrap(err, "close temp spec file"), OK: false}
|
||||
}
|
||||
defer coreio.Local.Delete(tmpPath)
|
||||
|
||||
if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil {
|
||||
return core.Result{Value: cli.Wrap(err, "generate spec"), OK: false}
|
||||
}
|
||||
resolvedSpecFile = tmpPath
|
||||
}
|
||||
|
||||
gen.SpecPath = resolvedSpecFile
|
||||
|
||||
for _, l := range languages {
|
||||
cli.Dim("Generating " + l + " SDK...")
|
||||
if err := gen.Generate(cli.Context(), l); err != nil {
|
||||
return core.Result{Value: cli.Wrap(err, "generate "+l), OK: false}
|
||||
}
|
||||
cli.Dim(" Done: " + output + "/" + l + "/")
|
||||
}
|
||||
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
|
||||
return newSpecBuilder(specBuilderConfig{
|
||||
title: cfg.title,
|
||||
summary: cfg.summary,
|
||||
description: cfg.description,
|
||||
version: cfg.version,
|
||||
swaggerPath: cfg.swaggerPath,
|
||||
graphqlPath: cfg.graphqlPath,
|
||||
graphqlPlayground: cfg.graphqlPlayground,
|
||||
graphqlPlaygroundPath: cfg.graphqlPlaygroundPath,
|
||||
ssePath: cfg.ssePath,
|
||||
wsPath: cfg.wsPath,
|
||||
pprofEnabled: cfg.pprofEnabled,
|
||||
expvarEnabled: cfg.expvarEnabled,
|
||||
cacheEnabled: cfg.cacheEnabled,
|
||||
cacheTTL: cfg.cacheTTL,
|
||||
cacheMaxEntries: cfg.cacheMaxEntries,
|
||||
cacheMaxBytes: cfg.cacheMaxBytes,
|
||||
i18nDefaultLocale: cfg.i18nDefaultLocale,
|
||||
i18nSupportedLocales: cfg.i18nSupportedLocales,
|
||||
authentikIssuer: cfg.authentikIssuer,
|
||||
authentikClientID: cfg.authentikClientID,
|
||||
authentikTrustedProxy: cfg.authentikTrustedProxy,
|
||||
authentikPublicPaths: cfg.authentikPublicPaths,
|
||||
termsURL: cfg.termsURL,
|
||||
contactName: cfg.contactName,
|
||||
contactURL: cfg.contactURL,
|
||||
contactEmail: cfg.contactEmail,
|
||||
licenseName: cfg.licenseName,
|
||||
licenseURL: cfg.licenseURL,
|
||||
externalDocsDescription: cfg.externalDocsDescription,
|
||||
externalDocsURL: cfg.externalDocsURL,
|
||||
servers: cfg.servers,
|
||||
securitySchemes: cfg.securitySchemes,
|
||||
})
|
||||
return newSpecBuilder(cfg)
|
||||
}
|
||||
|
||||
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
|
||||
return specGroupsIter(goapi.NewToolBridge("/tools"))
|
||||
}
|
||||
|
||||
// sdkConfigFromOptions mirrors specConfigFromOptions but falls back to
|
||||
// SDK-specific title/description/version defaults.
|
||||
func sdkConfigFromOptions(opts core.Options) specBuilderConfig {
|
||||
cfg := specConfigFromOptions(opts)
|
||||
cfg.title = stringOr(opts.String("title"), defaultSDKTitle)
|
||||
cfg.description = stringOr(opts.String("description"), defaultSDKDescription)
|
||||
cfg.version = stringOr(opts.String("version"), defaultSDKVersion)
|
||||
return cfg
|
||||
}
|
||||
|
|
|
|||
98
cmd/api/cmd_sdk_test.go
Normal file
98
cmd/api/cmd_sdk_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
// TestCmdSdk_AddSDKCommand_Good verifies the sdk command registers under
|
||||
// the expected api/sdk path with an executable Action.
|
||||
func TestCmdSdk_AddSDKCommand_Good(t *testing.T) {
|
||||
c := core.New()
|
||||
addSDKCommand(c)
|
||||
|
||||
r := c.Command("api/sdk")
|
||||
if !r.OK {
|
||||
t.Fatalf("expected api/sdk command to be registered")
|
||||
}
|
||||
cmd, ok := r.Value.(*core.Command)
|
||||
if !ok {
|
||||
t.Fatalf("expected *core.Command, got %T", r.Value)
|
||||
}
|
||||
if cmd.Action == nil {
|
||||
t.Fatal("expected non-nil Action on api/sdk")
|
||||
}
|
||||
if cmd.Description == "" {
|
||||
t.Fatal("expected Description on api/sdk")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSdk_SdkAction_Bad_RequiresLanguage rejects invocations that omit
|
||||
// the --lang flag entirely.
|
||||
func TestCmdSdk_SdkAction_Bad_RequiresLanguage(t *testing.T) {
|
||||
opts := core.NewOptions()
|
||||
r := sdkAction(opts)
|
||||
if r.OK {
|
||||
t.Fatal("expected sdk action to fail without --lang")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSdk_SdkAction_Bad_EmptyLanguageList rejects --lang values that
|
||||
// resolve to no real language entries after splitting and trimming.
|
||||
func TestCmdSdk_SdkAction_Bad_EmptyLanguageList(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "lang", Value: " , , "},
|
||||
)
|
||||
r := sdkAction(opts)
|
||||
if r.OK {
|
||||
t.Fatal("expected sdk action to fail when --lang only contains separators")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge verifies the SDK
|
||||
// builder always exposes the bundled tools route group.
|
||||
func TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge(t *testing.T) {
|
||||
snapshot := api.RegisteredSpecGroups()
|
||||
api.ResetSpecGroups()
|
||||
t.Cleanup(func() {
|
||||
api.ResetSpecGroups()
|
||||
api.RegisterSpecGroups(snapshot...)
|
||||
})
|
||||
|
||||
groups := collectRouteGroups(sdkSpecGroupsIter())
|
||||
if len(groups) == 0 {
|
||||
t.Fatal("expected at least the bundled tools bridge")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, g := range groups {
|
||||
if g.BasePath() == "/tools" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected /tools route group in sdk spec iterator")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults exercises the
|
||||
// SDK-specific default fallbacks for empty title/description/version.
|
||||
func TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults(t *testing.T) {
|
||||
opts := core.NewOptions()
|
||||
cfg := sdkConfigFromOptions(opts)
|
||||
if cfg.title != defaultSDKTitle {
|
||||
t.Fatalf("expected default SDK title, got %q", cfg.title)
|
||||
}
|
||||
if cfg.description != defaultSDKDescription {
|
||||
t.Fatalf("expected default SDK description, got %q", cfg.description)
|
||||
}
|
||||
if cfg.version != defaultSDKVersion {
|
||||
t.Fatalf("expected default SDK version, got %q", cfg.version)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,49 +7,46 @@ import (
|
|||
"os"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
|
||||
"dappco.re/go/core/cli/pkg/cli"
|
||||
|
||||
goapi "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
func addSpecCommand(parent *cli.Command) {
|
||||
var (
|
||||
output string
|
||||
format string
|
||||
cfg specBuilderConfig
|
||||
)
|
||||
|
||||
cfg.title = "Lethean Core API"
|
||||
cfg.description = "Lethean Core API"
|
||||
cfg.version = "1.0.0"
|
||||
|
||||
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
||||
// Build spec from all route groups registered for CLI generation.
|
||||
builder, err := newSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bridge := goapi.NewToolBridge("/tools")
|
||||
groups := specGroupsIter(bridge)
|
||||
|
||||
if output != "" {
|
||||
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = os.Stderr.Write([]byte(core.Sprintf("Spec written to %s\n", output)))
|
||||
return nil
|
||||
}
|
||||
|
||||
return goapi.ExportSpecIter(os.Stdout, format, builder, groups)
|
||||
func addSpecCommand(c *core.Core) {
|
||||
c.Command("api/spec", core.Command{
|
||||
Description: "Generate OpenAPI specification",
|
||||
Action: specAction,
|
||||
})
|
||||
}
|
||||
|
||||
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
|
||||
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
||||
registerSpecBuilderFlags(cmd, &cfg)
|
||||
func specAction(opts core.Options) core.Result {
|
||||
cfg := specConfigFromOptions(opts)
|
||||
output := opts.String("output")
|
||||
format := opts.String("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
builder, err := newSpecBuilder(cfg)
|
||||
if err != nil {
|
||||
return core.Result{Value: err, OK: false}
|
||||
}
|
||||
|
||||
bridge := goapi.NewToolBridge("/tools")
|
||||
groups := specGroupsIter(bridge)
|
||||
|
||||
if output != "" {
|
||||
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
|
||||
return core.Result{Value: cli.Wrap(err, "write spec"), OK: false}
|
||||
}
|
||||
cli.Dim("Spec written to " + output)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
if err := goapi.ExportSpecIter(os.Stdout, format, builder, groups); err != nil {
|
||||
return core.Result{Value: cli.Wrap(err, "render spec"), OK: false}
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
func parseServers(raw string) []string {
|
||||
|
|
@ -64,42 +61,55 @@ func parseSecuritySchemes(raw string) (map[string]any, error) {
|
|||
|
||||
var schemes map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &schemes); err != nil {
|
||||
return nil, cli.Err("invalid security schemes JSON: %w", err)
|
||||
return nil, cli.Wrap(err, "invalid security schemes JSON")
|
||||
}
|
||||
return schemes, nil
|
||||
}
|
||||
|
||||
func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) {
|
||||
cli.StringFlag(cmd, &cfg.title, "title", "t", cfg.title, "API title in spec")
|
||||
cli.StringFlag(cmd, &cfg.summary, "summary", "", cfg.summary, "OpenAPI info summary in spec")
|
||||
cli.StringFlag(cmd, &cfg.description, "description", "d", cfg.description, "API description in spec")
|
||||
cli.StringFlag(cmd, &cfg.version, "version", "V", cfg.version, "API version in spec")
|
||||
cli.StringFlag(cmd, &cfg.swaggerPath, "swagger-path", "", "", "Swagger UI path in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.graphqlPath, "graphql-path", "", "", "GraphQL endpoint path in generated spec")
|
||||
cli.BoolFlag(cmd, &cfg.graphqlPlayground, "graphql-playground", "", false, "Include the GraphQL playground endpoint in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.graphqlPlaygroundPath, "graphql-playground-path", "", "", "GraphQL playground path in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.ssePath, "sse-path", "", "", "SSE endpoint path in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.wsPath, "ws-path", "", "", "WebSocket endpoint path in generated spec")
|
||||
cli.BoolFlag(cmd, &cfg.pprofEnabled, "pprof", "", false, "Include pprof endpoints in generated spec")
|
||||
cli.BoolFlag(cmd, &cfg.expvarEnabled, "expvar", "", false, "Include expvar endpoint in generated spec")
|
||||
cli.BoolFlag(cmd, &cfg.cacheEnabled, "cache", "", false, "Include cache metadata in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.cacheTTL, "cache-ttl", "", "", "Cache TTL in generated spec")
|
||||
cli.IntFlag(cmd, &cfg.cacheMaxEntries, "cache-max-entries", "", 0, "Cache max entries in generated spec")
|
||||
cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec")
|
||||
cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec")
|
||||
cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec")
|
||||
cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec")
|
||||
cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec")
|
||||
cli.StringFlag(cmd, &cfg.contactEmail, "contact-email", "", "", "OpenAPI contact email in spec")
|
||||
cli.StringFlag(cmd, &cfg.licenseName, "license-name", "", "", "OpenAPI licence name in spec")
|
||||
cli.StringFlag(cmd, &cfg.licenseURL, "license-url", "", "", "OpenAPI licence URL in spec")
|
||||
cli.StringFlag(cmd, &cfg.externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec")
|
||||
cli.StringFlag(cmd, &cfg.externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec")
|
||||
cli.StringFlag(cmd, &cfg.servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
|
||||
cli.StringFlag(cmd, &cfg.securitySchemes, "security-schemes", "", "", "JSON object of custom OpenAPI security schemes")
|
||||
// specConfigFromOptions extracts a specBuilderConfig from the CLI options bag.
|
||||
// Callers supply flags via `--key=value` on the command line; the CLI parser
|
||||
// converts them to the option keys read here.
|
||||
func specConfigFromOptions(opts core.Options) specBuilderConfig {
|
||||
cfg := specBuilderConfig{
|
||||
title: stringOr(opts.String("title"), "Lethean Core API"),
|
||||
summary: opts.String("summary"),
|
||||
description: stringOr(opts.String("description"), "Lethean Core API"),
|
||||
version: stringOr(opts.String("version"), "1.0.0"),
|
||||
swaggerPath: opts.String("swagger-path"),
|
||||
graphqlPath: opts.String("graphql-path"),
|
||||
graphqlPlayground: opts.Bool("graphql-playground"),
|
||||
graphqlPlaygroundPath: opts.String("graphql-playground-path"),
|
||||
ssePath: opts.String("sse-path"),
|
||||
wsPath: opts.String("ws-path"),
|
||||
pprofEnabled: opts.Bool("pprof"),
|
||||
expvarEnabled: opts.Bool("expvar"),
|
||||
cacheEnabled: opts.Bool("cache"),
|
||||
cacheTTL: opts.String("cache-ttl"),
|
||||
cacheMaxEntries: opts.Int("cache-max-entries"),
|
||||
cacheMaxBytes: opts.Int("cache-max-bytes"),
|
||||
i18nDefaultLocale: opts.String("i18n-default-locale"),
|
||||
i18nSupportedLocales: opts.String("i18n-supported-locales"),
|
||||
authentikIssuer: opts.String("authentik-issuer"),
|
||||
authentikClientID: opts.String("authentik-client-id"),
|
||||
authentikTrustedProxy: opts.Bool("authentik-trusted-proxy"),
|
||||
authentikPublicPaths: opts.String("authentik-public-paths"),
|
||||
termsURL: opts.String("terms-of-service"),
|
||||
contactName: opts.String("contact-name"),
|
||||
contactURL: opts.String("contact-url"),
|
||||
contactEmail: opts.String("contact-email"),
|
||||
licenseName: opts.String("license-name"),
|
||||
licenseURL: opts.String("license-url"),
|
||||
externalDocsDescription: opts.String("external-docs-description"),
|
||||
externalDocsURL: opts.String("external-docs-url"),
|
||||
servers: opts.String("server"),
|
||||
securitySchemes: opts.String("security-schemes"),
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func stringOr(v, fallback string) string {
|
||||
if core.Trim(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
191
cmd/api/cmd_spec_test.go
Normal file
191
cmd/api/cmd_spec_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"iter"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "dappco.re/go/core/api"
|
||||
)
|
||||
|
||||
type specCmdStubGroup struct{}
|
||||
|
||||
func (specCmdStubGroup) Name() string { return "registered" }
|
||||
func (specCmdStubGroup) BasePath() string { return "/registered" }
|
||||
func (specCmdStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
|
||||
func (specCmdStubGroup) Describe() []api.RouteDescription {
|
||||
return []api.RouteDescription{
|
||||
{
|
||||
Method: "GET",
|
||||
Path: "/ping",
|
||||
Summary: "Ping registered group",
|
||||
Tags: []string{"registered"},
|
||||
Response: map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func collectRouteGroups(groups iter.Seq[api.RouteGroup]) []api.RouteGroup {
|
||||
out := make([]api.RouteGroup, 0)
|
||||
for group := range groups {
|
||||
out = append(out, group)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestCmdSpec_AddSpecCommand_Good verifies the spec command registers under
|
||||
// the expected api/spec path with an executable Action.
|
||||
func TestCmdSpec_AddSpecCommand_Good(t *testing.T) {
|
||||
c := core.New()
|
||||
addSpecCommand(c)
|
||||
|
||||
r := c.Command("api/spec")
|
||||
if !r.OK {
|
||||
t.Fatalf("expected api/spec command to be registered")
|
||||
}
|
||||
cmd, ok := r.Value.(*core.Command)
|
||||
if !ok {
|
||||
t.Fatalf("expected *core.Command, got %T", r.Value)
|
||||
}
|
||||
if cmd.Action == nil {
|
||||
t.Fatal("expected non-nil Action on api/spec")
|
||||
}
|
||||
if cmd.Description == "" {
|
||||
t.Fatal("expected Description on api/spec")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSpec_SpecAction_Good_WritesJSONToFile exercises the spec action with
|
||||
// an output file flag and verifies the resulting OpenAPI document parses.
|
||||
func TestCmdSpec_SpecAction_Good_WritesJSONToFile(t *testing.T) {
|
||||
outputFile := t.TempDir() + "/spec.json"
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "output", Value: outputFile},
|
||||
core.Option{Key: "format", Value: "json"},
|
||||
)
|
||||
|
||||
r := specAction(opts)
|
||||
if !r.OK {
|
||||
t.Fatalf("expected OK result, got %v", r.Value)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("expected spec file to be written: %v", err)
|
||||
}
|
||||
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("expected valid JSON spec, got error: %v", err)
|
||||
}
|
||||
if spec["openapi"] == nil {
|
||||
t.Fatal("expected openapi field in generated spec")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved ensures that flag
|
||||
// keys from the CLI parser populate the spec builder configuration.
|
||||
func TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved(t *testing.T) {
|
||||
opts := core.NewOptions(
|
||||
core.Option{Key: "title", Value: "Custom API"},
|
||||
core.Option{Key: "summary", Value: "Brief summary"},
|
||||
core.Option{Key: "description", Value: "Long description"},
|
||||
core.Option{Key: "version", Value: "9.9.9"},
|
||||
core.Option{Key: "swagger-path", Value: "/docs"},
|
||||
core.Option{Key: "graphql-playground", Value: true},
|
||||
core.Option{Key: "cache", Value: true},
|
||||
core.Option{Key: "cache-max-entries", Value: 100},
|
||||
core.Option{Key: "i18n-supported-locales", Value: "en-GB,fr"},
|
||||
)
|
||||
|
||||
cfg := specConfigFromOptions(opts)
|
||||
|
||||
if cfg.title != "Custom API" {
|
||||
t.Fatalf("expected title=Custom API, got %q", cfg.title)
|
||||
}
|
||||
if cfg.summary != "Brief summary" {
|
||||
t.Fatalf("expected summary preserved, got %q", cfg.summary)
|
||||
}
|
||||
if cfg.version != "9.9.9" {
|
||||
t.Fatalf("expected version=9.9.9, got %q", cfg.version)
|
||||
}
|
||||
if cfg.swaggerPath != "/docs" {
|
||||
t.Fatalf("expected swagger path, got %q", cfg.swaggerPath)
|
||||
}
|
||||
if !cfg.graphqlPlayground {
|
||||
t.Fatal("expected graphql playground enabled")
|
||||
}
|
||||
if !cfg.cacheEnabled {
|
||||
t.Fatal("expected cache enabled")
|
||||
}
|
||||
if cfg.cacheMaxEntries != 100 {
|
||||
t.Fatalf("expected cacheMaxEntries=100, got %d", cfg.cacheMaxEntries)
|
||||
}
|
||||
if cfg.i18nSupportedLocales != "en-GB,fr" {
|
||||
t.Fatalf("expected i18n supported locales, got %q", cfg.i18nSupportedLocales)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied ensures empty values
|
||||
// do not blank out required defaults like title, description, version.
|
||||
func TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied(t *testing.T) {
|
||||
opts := core.NewOptions()
|
||||
cfg := specConfigFromOptions(opts)
|
||||
|
||||
if cfg.title != "Lethean Core API" {
|
||||
t.Fatalf("expected default title, got %q", cfg.title)
|
||||
}
|
||||
if cfg.description != "Lethean Core API" {
|
||||
t.Fatalf("expected default description, got %q", cfg.description)
|
||||
}
|
||||
if cfg.version != "1.0.0" {
|
||||
t.Fatalf("expected default version, got %q", cfg.version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback covers the awkward
|
||||
// whitespace path where an option is set to whitespace but should still
|
||||
// fall back to the supplied default.
|
||||
func TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback(t *testing.T) {
|
||||
if got := stringOr(" ", "fallback"); got != "fallback" {
|
||||
t.Fatalf("expected whitespace to fall back to default, got %q", got)
|
||||
}
|
||||
if got := stringOr("value", "fallback"); got != "value" {
|
||||
t.Fatalf("expected explicit value to win, got %q", got)
|
||||
}
|
||||
if got := stringOr("", ""); got != "" {
|
||||
t.Fatalf("expected empty/empty to remain empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecGroupsIter_Good_DeduplicatesExtraBridge verifies the iterator does
|
||||
// not emit a duplicate when the registered groups already contain a tool
|
||||
// bridge with the same base path.
|
||||
func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) {
|
||||
snapshot := api.RegisteredSpecGroups()
|
||||
api.ResetSpecGroups()
|
||||
t.Cleanup(func() {
|
||||
api.ResetSpecGroups()
|
||||
api.RegisterSpecGroups(snapshot...)
|
||||
})
|
||||
|
||||
group := specCmdStubGroup{}
|
||||
api.RegisterSpecGroups(group)
|
||||
|
||||
groups := collectRouteGroups(specGroupsIter(group))
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("expected duplicate extra group to be skipped, got %d groups", len(groups))
|
||||
}
|
||||
if groups[0].Name() != group.Name() || groups[0].BasePath() != group.BasePath() {
|
||||
t.Fatalf("expected original group to be preserved, got %s at %s", groups[0].Name(), groups[0].BasePath())
|
||||
}
|
||||
}
|
||||
1422
cmd/api/cmd_test.go
1422
cmd/api/cmd_test.go
File diff suppressed because it is too large
Load diff
3
group.go
3
group.go
|
|
@ -89,6 +89,9 @@ type RouteDescription struct {
|
|||
SunsetDate string
|
||||
// Replacement points to the successor endpoint URL, when known.
|
||||
Replacement string
|
||||
// NoticeURL points to a detailed deprecation notice or migration guide,
|
||||
// surfaced as the API-Deprecation-Notice-URL response header per spec §8.
|
||||
NoticeURL string
|
||||
// StatusCode is the documented 2xx success status code.
|
||||
// Zero defaults to 200.
|
||||
StatusCode int
|
||||
|
|
|
|||
31
openapi.go
31
openapi.go
|
|
@ -354,8 +354,13 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
|
|||
for _, rd := range g.descs {
|
||||
fullPath := joinOpenAPIPath(g.basePath, rd.Path)
|
||||
method := core.Lower(rd.Method)
|
||||
deprecated := rd.Deprecated || core.Trim(rd.SunsetDate) != "" || core.Trim(rd.Replacement) != ""
|
||||
deprecated := rd.Deprecated || core.Trim(rd.SunsetDate) != "" || core.Trim(rd.Replacement) != "" || core.Trim(rd.NoticeURL) != ""
|
||||
deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement)
|
||||
if deprecated && core.Trim(rd.NoticeURL) != "" && deprecationHeaders != nil {
|
||||
deprecationHeaders["API-Deprecation-Notice-URL"] = map[string]any{
|
||||
"$ref": "#/components/headers/apiDeprecationNoticeURL",
|
||||
}
|
||||
}
|
||||
isPublic := isPublicPathForList(fullPath, publicPaths)
|
||||
security := rd.Security
|
||||
if isPublic {
|
||||
|
|
@ -684,7 +689,9 @@ func healthResponses() map[string]any {
|
|||
}
|
||||
|
||||
// deprecationResponseHeaders documents the standard deprecation headers for
|
||||
// deprecated or sunsetted operations.
|
||||
// deprecated or sunsetted operations. The header set mirrors what
|
||||
// ApiSunset/ApiSunsetWith emit at runtime, including the spec §8 custom
|
||||
// headers (API-Suggested-Replacement, API-Deprecation-Notice-URL).
|
||||
func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) map[string]any {
|
||||
sunsetDate = core.Trim(sunsetDate)
|
||||
replacement = core.Trim(replacement)
|
||||
|
|
@ -712,13 +719,18 @@ func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string)
|
|||
headers["Link"] = map[string]any{
|
||||
"$ref": "#/components/headers/link",
|
||||
}
|
||||
headers["API-Suggested-Replacement"] = map[string]any{
|
||||
"$ref": "#/components/headers/apiSuggestedReplacement",
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// deprecationHeaderComponents returns reusable OpenAPI header components for
|
||||
// the standard deprecation and sunset middleware headers.
|
||||
// the standard deprecation and sunset middleware headers. Includes both the
|
||||
// IETF-standard headers (Deprecation, Sunset, Link) and the custom spec §8
|
||||
// headers used to communicate replacement endpoints and migration guides.
|
||||
func deprecationHeaderComponents() map[string]any {
|
||||
return map[string]any{
|
||||
"deprecation": map[string]any{
|
||||
|
|
@ -747,6 +759,19 @@ func deprecationHeaderComponents() map[string]any {
|
|||
"type": "string",
|
||||
},
|
||||
},
|
||||
"apiSuggestedReplacement": map[string]any{
|
||||
"description": "Suggested replacement endpoint for clients to migrate to.",
|
||||
"schema": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"apiDeprecationNoticeURL": map[string]any{
|
||||
"description": "URL pointing to a detailed deprecation notice.",
|
||||
"schema": map[string]any{
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
options.go
21
options.go
|
|
@ -153,6 +153,27 @@ func WithWSHandler(h http.Handler) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithWebSocket registers a Gin-native WebSocket handler at GET /ws.
|
||||
//
|
||||
// This is the gin-handler form of WithWSHandler. The handler receives the
|
||||
// request via *gin.Context and is responsible for performing the upgrade
|
||||
// (typically with gorilla/websocket) and managing the message loop.
|
||||
// Use WithWSPath to customise the route before mounting the handler.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.New(api.WithWebSocket(func(c *gin.Context) {
|
||||
// // upgrade and handle messages
|
||||
// }))
|
||||
func WithWebSocket(h gin.HandlerFunc) Option {
|
||||
return func(e *Engine) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
e.wsGinHandler = h
|
||||
}
|
||||
}
|
||||
|
||||
// WithWSPath sets a custom URL path for the WebSocket endpoint.
|
||||
// The default path is "/ws".
|
||||
//
|
||||
|
|
|
|||
59
sunset.go
59
sunset.go
|
|
@ -11,17 +11,58 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SunsetOption customises the behaviour of ApiSunsetWith. Use the supplied
|
||||
// constructors (e.g. WithSunsetNoticeURL) to compose the desired metadata
|
||||
// without breaking the simpler ApiSunset signature.
|
||||
//
|
||||
// mw := api.ApiSunsetWith("2025-06-01", "/api/v2/users",
|
||||
// api.WithSunsetNoticeURL("https://docs.example.com/deprecation/billing"),
|
||||
// )
|
||||
type SunsetOption func(*sunsetConfig)
|
||||
|
||||
// sunsetConfig carries optional metadata for ApiSunsetWith.
|
||||
type sunsetConfig struct {
|
||||
noticeURL string
|
||||
}
|
||||
|
||||
// WithSunsetNoticeURL adds the API-Deprecation-Notice-URL header documented
|
||||
// in spec §8 to every response. The URL should point to a human-readable
|
||||
// migration guide for the deprecated endpoint.
|
||||
//
|
||||
// api.ApiSunsetWith("2026-04-30", "POST /api/v2/billing/invoices",
|
||||
// api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"),
|
||||
// )
|
||||
func WithSunsetNoticeURL(url string) SunsetOption {
|
||||
return func(cfg *sunsetConfig) {
|
||||
cfg.noticeURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// ApiSunset returns middleware that marks a route or group as deprecated.
|
||||
//
|
||||
// The middleware appends standard deprecation headers to every response:
|
||||
// Deprecation, optional Sunset, optional Link, and X-API-Warn. Existing header
|
||||
// values are preserved so downstream middleware and handlers can keep their own
|
||||
// link relations or warning metadata.
|
||||
// Deprecation, optional Sunset, optional Link, optional API-Suggested-Replacement,
|
||||
// and X-API-Warn. Existing header values are preserved so downstream middleware
|
||||
// and handlers can keep their own link relations or warning metadata.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rg.Use(api.ApiSunset("2025-06-01", "/api/v2/users"))
|
||||
func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc {
|
||||
return ApiSunsetWith(sunsetDate, replacement)
|
||||
}
|
||||
|
||||
// ApiSunsetWith is the extensible form of ApiSunset. It accepts SunsetOption
|
||||
// values to attach optional metadata such as the deprecation notice URL.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rg.Use(api.ApiSunsetWith(
|
||||
// "2026-04-30",
|
||||
// "POST /api/v2/billing/invoices",
|
||||
// api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"),
|
||||
// ))
|
||||
func ApiSunsetWith(sunsetDate, replacement string, opts ...SunsetOption) gin.HandlerFunc {
|
||||
sunsetDate = core.Trim(sunsetDate)
|
||||
replacement = core.Trim(replacement)
|
||||
formatted := formatSunsetDate(sunsetDate)
|
||||
|
|
@ -30,6 +71,14 @@ func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc {
|
|||
warning = "This endpoint is deprecated and will be removed on " + sunsetDate + "."
|
||||
}
|
||||
|
||||
cfg := &sunsetConfig{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(cfg)
|
||||
}
|
||||
}
|
||||
noticeURL := core.Trim(cfg.noticeURL)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
|
|
@ -39,6 +88,10 @@ func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc {
|
|||
}
|
||||
if replacement != "" {
|
||||
c.Writer.Header().Add("Link", "<"+replacement+">; rel=\"successor-version\"")
|
||||
c.Writer.Header().Add("API-Suggested-Replacement", replacement)
|
||||
}
|
||||
if noticeURL != "" {
|
||||
c.Writer.Header().Add("API-Deprecation-Notice-URL", noticeURL)
|
||||
}
|
||||
c.Writer.Header().Add("X-API-Warn", warning)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,11 +72,73 @@ func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) {
|
|||
if got := w.Header().Get("Link"); got != "</api/v2/status>; rel=\"successor-version\"" {
|
||||
t.Fatalf("expected successor Link header, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("API-Suggested-Replacement"); got != "/api/v2/status" {
|
||||
t.Fatalf("expected API-Suggested-Replacement to mirror replacement URL, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-API-Warn"); got != "This endpoint is deprecated and will be removed on 2025-06-01." {
|
||||
t.Fatalf("expected deprecation warning, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiSunsetWith_Good_AddsNoticeURLHeader exercises ApiSunsetWith with the
|
||||
// WithSunsetNoticeURL option to verify the spec §8 notice header is emitted.
|
||||
func TestApiSunsetWith_Good_AddsNoticeURLHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mw := api.ApiSunsetWith(
|
||||
"2026-04-30",
|
||||
"POST /api/v2/billing/invoices",
|
||||
api.WithSunsetNoticeURL("https://docs.api.dappco.re/deprecation/billing"),
|
||||
)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mw)
|
||||
r.GET("/billing", func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("ok")) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/billing", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if got := w.Header().Get("API-Deprecation-Notice-URL"); got != "https://docs.api.dappco.re/deprecation/billing" {
|
||||
t.Fatalf("expected API-Deprecation-Notice-URL header, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("API-Suggested-Replacement"); got != "POST /api/v2/billing/invoices" {
|
||||
t.Fatalf("expected API-Suggested-Replacement to mirror replacement, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiSunsetWith_Bad_OmitsEmptyOptionalHeaders ensures empty option values
|
||||
// do not emit blank headers, keeping the response surface clean.
|
||||
func TestApiSunsetWith_Bad_OmitsEmptyOptionalHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mw := api.ApiSunsetWith("", "", api.WithSunsetNoticeURL(" "))
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mw)
|
||||
r.GET("/x", func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("ok")) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/x", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if got := w.Header().Get("Sunset"); got != "" {
|
||||
t.Fatalf("expected no Sunset header for empty date, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Link"); got != "" {
|
||||
t.Fatalf("expected no Link header for empty replacement, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("API-Suggested-Replacement"); got != "" {
|
||||
t.Fatalf("expected no API-Suggested-Replacement for empty replacement, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("API-Deprecation-Notice-URL"); got != "" {
|
||||
t.Fatalf("expected no API-Deprecation-Notice-URL for blank URL, got %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Deprecation"); got != "true" {
|
||||
t.Fatalf("expected Deprecation=true even with no metadata, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSunset_Good_PreservesExistingLinkHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func (e *Engine) TransportConfig() TransportConfig {
|
|||
|
||||
cfg := TransportConfig{
|
||||
SwaggerEnabled: e.swaggerEnabled,
|
||||
WSEnabled: e.wsHandler != nil,
|
||||
WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil,
|
||||
SSEEnabled: e.sseBroker != nil,
|
||||
PprofEnabled: e.pprofEnabled,
|
||||
ExpvarEnabled: e.expvarEnabled,
|
||||
|
|
@ -58,7 +58,7 @@ func (e *Engine) TransportConfig() TransportConfig {
|
|||
if gql.Path != "" {
|
||||
cfg.GraphQLPath = gql.Path
|
||||
}
|
||||
if e.wsHandler != nil || core.Trim(e.wsPath) != "" {
|
||||
if e.wsHandler != nil || e.wsGinHandler != nil || core.Trim(e.wsPath) != "" {
|
||||
cfg.WSPath = resolveWSPath(e.wsPath)
|
||||
}
|
||||
if e.sseBroker != nil || core.Trim(e.ssePath) != "" {
|
||||
|
|
|
|||
208
webhook.go
Normal file
208
webhook.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
)
|
||||
|
||||
// WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook
|
||||
// payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and
|
||||
// a signature header so receivers can validate authenticity, integrity, and
|
||||
// reject replays.
|
||||
//
|
||||
// The signature format mirrors the PHP-side WebhookSignature service:
|
||||
//
|
||||
// signature = HMAC-SHA256(timestamp + "." + payload, secret)
|
||||
//
|
||||
// signer := api.NewWebhookSigner("supersecret")
|
||||
// headers := signer.Headers([]byte(`{"event":"workspace.created"}`))
|
||||
// // headers["X-Webhook-Signature"] is the hex digest
|
||||
// // headers["X-Webhook-Timestamp"] is the Unix timestamp string
|
||||
type WebhookSigner struct {
|
||||
secret []byte
|
||||
tolerance time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
// WebhookSignatureHeader is the response/request header that carries the
|
||||
// HMAC-SHA256 hex digest of a signed webhook payload.
|
||||
WebhookSignatureHeader = "X-Webhook-Signature"
|
||||
|
||||
// WebhookTimestampHeader is the response/request header that carries the
|
||||
// Unix timestamp used to derive the signature.
|
||||
WebhookTimestampHeader = "X-Webhook-Timestamp"
|
||||
|
||||
// DefaultWebhookTolerance is the maximum age of a webhook timestamp the
|
||||
// signer will accept. Five minutes mirrors the PHP-side default and
|
||||
// allows for reasonable clock skew between sender and receiver.
|
||||
DefaultWebhookTolerance = 5 * time.Minute
|
||||
)
|
||||
|
||||
// NewWebhookSigner constructs a signer that uses the given shared secret with
|
||||
// the default timestamp tolerance (five minutes).
|
||||
//
|
||||
// signer := api.NewWebhookSigner("shared-secret")
|
||||
func NewWebhookSigner(secret string) *WebhookSigner {
|
||||
return &WebhookSigner{
|
||||
secret: []byte(secret),
|
||||
tolerance: DefaultWebhookTolerance,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWebhookSignerWithTolerance constructs a signer with a custom timestamp
|
||||
// tolerance. A tolerance of zero or negative falls back to
|
||||
// DefaultWebhookTolerance to avoid silently disabling replay protection.
|
||||
//
|
||||
// signer := api.NewWebhookSignerWithTolerance("secret", 30*time.Second)
|
||||
func NewWebhookSignerWithTolerance(secret string, tolerance time.Duration) *WebhookSigner {
|
||||
if tolerance <= 0 {
|
||||
tolerance = DefaultWebhookTolerance
|
||||
}
|
||||
return &WebhookSigner{
|
||||
secret: []byte(secret),
|
||||
tolerance: tolerance,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateWebhookSecret returns a hex-encoded 32-byte random string suitable
|
||||
// for use as a webhook signing secret. Output length is 64 characters.
|
||||
//
|
||||
// secret, err := api.GenerateWebhookSecret()
|
||||
// // secret = "9f1a..." (64 hex chars)
|
||||
func GenerateWebhookSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", core.E("WebhookSigner.GenerateSecret", "read random bytes", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Tolerance returns the configured maximum age tolerance for verification.
|
||||
//
|
||||
// d := signer.Tolerance()
|
||||
func (s *WebhookSigner) Tolerance() time.Duration {
|
||||
if s == nil || s.tolerance <= 0 {
|
||||
return DefaultWebhookTolerance
|
||||
}
|
||||
return s.tolerance
|
||||
}
|
||||
|
||||
// Sign returns the hex-encoded HMAC-SHA256 of "timestamp.payload" using the
|
||||
// signer's secret. Callers may pass any non-nil payload bytes (typically a
|
||||
// JSON-encoded event body).
|
||||
//
|
||||
// digest := signer.Sign(payload, time.Now().Unix())
|
||||
func (s *WebhookSigner) Sign(payload []byte, timestamp int64) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(strconv.FormatInt(timestamp, 10)))
|
||||
mac.Write([]byte("."))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// SignNow signs the payload using the current Unix timestamp and returns
|
||||
// both the signature and the timestamp used.
|
||||
//
|
||||
// sig, ts := signer.SignNow(payload)
|
||||
func (s *WebhookSigner) SignNow(payload []byte) (string, int64) {
|
||||
now := time.Now().Unix()
|
||||
return s.Sign(payload, now), now
|
||||
}
|
||||
|
||||
// Headers returns the HTTP headers a sender should attach to a webhook
|
||||
// request: X-Webhook-Signature and X-Webhook-Timestamp. The current Unix
|
||||
// timestamp is used.
|
||||
//
|
||||
// for k, v := range signer.Headers(payload) {
|
||||
// req.Header.Set(k, v)
|
||||
// }
|
||||
func (s *WebhookSigner) Headers(payload []byte) map[string]string {
|
||||
sig, ts := s.SignNow(payload)
|
||||
return map[string]string{
|
||||
WebhookSignatureHeader: sig,
|
||||
WebhookTimestampHeader: strconv.FormatInt(ts, 10),
|
||||
}
|
||||
}
|
||||
|
||||
// Verify reports whether the given signature matches the payload and the
|
||||
// timestamp is within the signer's tolerance window. Comparison uses
|
||||
// hmac.Equal to avoid timing attacks.
|
||||
//
|
||||
// if signer.Verify(payload, sig, ts) {
|
||||
// // accept event
|
||||
// }
|
||||
func (s *WebhookSigner) Verify(payload []byte, signature string, timestamp int64) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if !s.IsTimestampValid(timestamp) {
|
||||
return false
|
||||
}
|
||||
expected := s.Sign(payload, timestamp)
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// VerifySignatureOnly compares the signature without checking timestamp
|
||||
// freshness. Use it when timestamp validation is performed elsewhere or in
|
||||
// tests where a stable timestamp is required.
|
||||
//
|
||||
// ok := signer.VerifySignatureOnly(payload, sig, fixedTimestamp)
|
||||
func (s *WebhookSigner) VerifySignatureOnly(payload []byte, signature string, timestamp int64) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
expected := s.Sign(payload, timestamp)
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// IsTimestampValid reports whether the given Unix timestamp is within the
|
||||
// signer's configured tolerance window relative to the current time.
|
||||
//
|
||||
// if !signer.IsTimestampValid(ts) {
|
||||
// return errors.New("webhook timestamp expired")
|
||||
// }
|
||||
func (s *WebhookSigner) IsTimestampValid(timestamp int64) bool {
|
||||
tol := s.Tolerance()
|
||||
now := time.Now().Unix()
|
||||
delta := now - timestamp
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return time.Duration(delta)*time.Second <= tol
|
||||
}
|
||||
|
||||
// VerifyRequest extracts the signature and timestamp headers from a request
|
||||
// and verifies the given payload against them. Returns false when the
|
||||
// headers are missing, malformed, or the signature does not match.
|
||||
//
|
||||
// if !signer.VerifyRequest(r, payload) {
|
||||
// http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||
// return
|
||||
// }
|
||||
func (s *WebhookSigner) VerifyRequest(r *http.Request, payload []byte) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
sig := r.Header.Get(WebhookSignatureHeader)
|
||||
rawTS := r.Header.Get(WebhookTimestampHeader)
|
||||
if sig == "" || rawTS == "" {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(rawTS, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.Verify(payload, sig, ts)
|
||||
}
|
||||
226
webhook_test.go
Normal file
226
webhook_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults verifies the
|
||||
// constructor sets up a usable signer with the documented default tolerance.
|
||||
func TestWebhook_NewWebhookSigner_Good_BuildsSignerWithDefaults(t *testing.T) {
|
||||
s := NewWebhookSigner("hello")
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil signer")
|
||||
}
|
||||
if s.Tolerance() != DefaultWebhookTolerance {
|
||||
t.Fatalf("expected default tolerance %s, got %s", DefaultWebhookTolerance, s.Tolerance())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance ensures the
|
||||
// custom-tolerance constructor is honoured for positive durations.
|
||||
func TestWebhook_NewWebhookSignerWithTolerance_Good_OverridesTolerance(t *testing.T) {
|
||||
s := NewWebhookSignerWithTolerance("x", 30*time.Second)
|
||||
if s.Tolerance() != 30*time.Second {
|
||||
t.Fatalf("expected 30s tolerance, got %s", s.Tolerance())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero verifies a
|
||||
// non-positive tolerance falls back to the documented default rather than
|
||||
// silently disabling replay protection.
|
||||
func TestWebhook_NewWebhookSignerWithTolerance_Ugly_FallsBackOnZero(t *testing.T) {
|
||||
s := NewWebhookSignerWithTolerance("x", 0)
|
||||
if s.Tolerance() != DefaultWebhookTolerance {
|
||||
t.Fatalf("expected default tolerance after zero override, got %s", s.Tolerance())
|
||||
}
|
||||
s = NewWebhookSignerWithTolerance("x", -5*time.Minute)
|
||||
if s.Tolerance() != DefaultWebhookTolerance {
|
||||
t.Fatalf("expected default tolerance after negative override, got %s", s.Tolerance())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars ensures the helper
|
||||
// returns a stable-format secret of the documented length.
|
||||
func TestWebhook_GenerateWebhookSecret_Good_Returns64HexChars(t *testing.T) {
|
||||
secret, err := GenerateWebhookSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(secret) != 64 {
|
||||
t.Fatalf("expected 64-char secret, got %d", len(secret))
|
||||
}
|
||||
for _, r := range secret {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
||||
t.Fatalf("expected lowercase hex characters, got %q", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Sign_Good_ProducesStableHexDigest ensures the sign helper is
|
||||
// deterministic for the same payload, secret, and timestamp.
|
||||
func TestWebhook_Sign_Good_ProducesStableHexDigest(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
first := s.Sign([]byte("payload"), 1234567890)
|
||||
second := s.Sign([]byte("payload"), 1234567890)
|
||||
if first != second {
|
||||
t.Fatalf("expected stable digest, got %s vs %s", first, second)
|
||||
}
|
||||
if len(first) != 64 {
|
||||
t.Fatalf("expected 64-char hex digest, got %d", len(first))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver guards the nil-receiver
|
||||
// behaviour required for safe defensive use in middleware.
|
||||
func TestWebhook_Sign_Bad_ReturnsEmptyOnNilReceiver(t *testing.T) {
|
||||
var s *WebhookSigner
|
||||
if got := s.Sign([]byte("x"), 1); got != "" {
|
||||
t.Fatalf("expected empty digest from nil receiver, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp verifies SignNow returns
|
||||
// a fresh timestamp that the verifier accepts.
|
||||
func TestWebhook_SignNow_Good_RoundTripsCurrentTimestamp(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
payload := []byte(`{"event":"workspace.created"}`)
|
||||
sig, ts := s.SignNow(payload)
|
||||
if !s.Verify(payload, sig, ts) {
|
||||
t.Fatal("expected SignNow output to verify")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Verify_Good_AcceptsMatchingSignature exercises the happy path of
|
||||
// matching payload/signature/timestamp inside the tolerance window.
|
||||
func TestWebhook_Verify_Good_AcceptsMatchingSignature(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
payload := []byte("body")
|
||||
now := time.Now().Unix()
|
||||
sig := s.Sign(payload, now)
|
||||
if !s.Verify(payload, sig, now) {
|
||||
t.Fatal("expected valid signature to verify")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Verify_Bad_RejectsTamperedPayload ensures payload mutation
|
||||
// invalidates the signature even when the secret/timestamp are valid.
|
||||
func TestWebhook_Verify_Bad_RejectsTamperedPayload(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
now := time.Now().Unix()
|
||||
sig := s.Sign([]byte("body"), now)
|
||||
if s.Verify([]byte("tampered"), sig, now) {
|
||||
t.Fatal("expected verification to fail for tampered payload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Verify_Bad_RejectsExpiredTimestamp ensures stale timestamps fail
|
||||
// even when the signature itself is valid for the older timestamp.
|
||||
func TestWebhook_Verify_Bad_RejectsExpiredTimestamp(t *testing.T) {
|
||||
s := NewWebhookSignerWithTolerance("secret", time.Minute)
|
||||
old := time.Now().Add(-2 * time.Minute).Unix()
|
||||
sig := s.Sign([]byte("body"), old)
|
||||
if s.Verify([]byte("body"), sig, old) {
|
||||
t.Fatal("expected stale timestamp to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp lets callers
|
||||
// validate signature integrity even when timestamps fall outside tolerance.
|
||||
func TestWebhook_VerifySignatureOnly_Good_IgnoresExpiredTimestamp(t *testing.T) {
|
||||
s := NewWebhookSignerWithTolerance("secret", time.Second)
|
||||
old := time.Now().Add(-time.Hour).Unix()
|
||||
sig := s.Sign([]byte("body"), old)
|
||||
if !s.VerifySignatureOnly([]byte("body"), sig, old) {
|
||||
t.Fatal("expected signature-only verification to pass for expired timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp verifies the header
|
||||
// helper returns both the signature and the timestamp that produced it.
|
||||
func TestWebhook_Headers_Good_PopulatesSignatureAndTimestamp(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
headers := s.Headers([]byte("body"))
|
||||
if headers[WebhookSignatureHeader] == "" {
|
||||
t.Fatal("expected signature header to be set")
|
||||
}
|
||||
if headers[WebhookTimestampHeader] == "" {
|
||||
t.Fatal("expected timestamp header to be set")
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(headers[WebhookTimestampHeader], 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("expected numeric timestamp header, got %q", headers[WebhookTimestampHeader])
|
||||
}
|
||||
if !s.Verify([]byte("body"), headers[WebhookSignatureHeader], ts) {
|
||||
t.Fatal("expected Headers() output to verify")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_VerifyRequest_Good_AcceptsValidHeaders uses the request helper
|
||||
// to ensure middleware can verify webhooks straight from an http.Request.
|
||||
func TestWebhook_VerifyRequest_Good_AcceptsValidHeaders(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
payload := []byte(`{"event":"link.clicked"}`)
|
||||
headers := s.Headers(payload)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader(string(payload)))
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
if !s.VerifyRequest(r, payload) {
|
||||
t.Fatal("expected VerifyRequest to accept valid signed request")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders rejects requests with
|
||||
// missing or malformed signature/timestamp headers.
|
||||
func TestWebhook_VerifyRequest_Bad_RejectsMissingHeaders(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
r := httptest.NewRequest(http.MethodPost, "/incoming", strings.NewReader("body"))
|
||||
if s.VerifyRequest(r, []byte("body")) {
|
||||
t.Fatal("expected VerifyRequest to fail with no headers")
|
||||
}
|
||||
|
||||
r.Header.Set(WebhookSignatureHeader, "deadbeef")
|
||||
if s.VerifyRequest(r, []byte("body")) {
|
||||
t.Fatal("expected VerifyRequest to fail with missing timestamp header")
|
||||
}
|
||||
|
||||
r.Header.Set(WebhookTimestampHeader, "not-a-number")
|
||||
if s.VerifyRequest(r, []byte("body")) {
|
||||
t.Fatal("expected VerifyRequest to fail with malformed timestamp header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse documents the
|
||||
// defensive nil-request guard so middleware can safely call this helper.
|
||||
func TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse(t *testing.T) {
|
||||
s := NewWebhookSigner("secret")
|
||||
if s.VerifyRequest(nil, []byte("body")) {
|
||||
t.Fatal("expected VerifyRequest(nil) to return false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance exercises the
|
||||
// inclusive boundary where the timestamp falls right at the tolerance edge.
|
||||
func TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance(t *testing.T) {
|
||||
s := NewWebhookSignerWithTolerance("x", time.Minute)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if !s.IsTimestampValid(now) {
|
||||
t.Fatal("expected current timestamp to be valid")
|
||||
}
|
||||
if !s.IsTimestampValid(now - 30) {
|
||||
t.Fatal("expected timestamp within tolerance to be valid")
|
||||
}
|
||||
if s.IsTimestampValid(now - 120) {
|
||||
t.Fatal("expected timestamp outside tolerance to be invalid")
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,95 @@ func TestWSEndpoint_Good_WithResponseMeta(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestWithWebSocket_Good_GinHandlerReceivesUpgrade verifies the gin-native
|
||||
// WithWebSocket option mounts a *gin.Context-aware handler on /ws.
|
||||
func TestWithWebSocket_Good_GinHandlerReceivesUpgrade(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
handler := func(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
t.Logf("upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteMessage(websocket.TextMessage, []byte("gin-hello"))
|
||||
}
|
||||
|
||||
e, err := api.New(api.WithWebSocket(handler))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(e.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial WebSocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read message: %v", err)
|
||||
}
|
||||
if string(msg) != "gin-hello" {
|
||||
t.Fatalf("expected message=%q, got %q", "gin-hello", string(msg))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithWebSocket_Bad_NilHandlerNoMount ensures a nil handler is silently
|
||||
// ignored rather than panicking on engine build.
|
||||
func TestWithWebSocket_Bad_NilHandlerNoMount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
e, err := api.New(api.WithWebSocket(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/ws", nil)
|
||||
e.Handler().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for /ws without handler, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithWebSocket_Ugly_GinHandlerWinsOverHTTPHandler verifies the gin form
|
||||
// takes precedence when both options are supplied so callers can iteratively
|
||||
// upgrade legacy registrations without behaviour drift.
|
||||
func TestWithWebSocket_Ugly_GinHandlerWinsOverHTTPHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
called := ""
|
||||
httpH := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = "http"
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
ginH := func(c *gin.Context) {
|
||||
called = "gin"
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
e, err := api.New(api.WithWSHandler(httpH), api.WithWebSocket(ginH))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/ws", nil)
|
||||
e.Handler().ServeHTTP(w, req)
|
||||
if called != "gin" {
|
||||
t.Fatalf("expected gin handler to win, got %q", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWSHandler_Good(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue