feat(api): add stable openapi operation ids

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 07:36:35 +00:00
parent 491c9a1c69
commit 16abc45efa
7 changed files with 120 additions and 2 deletions

3
go-io/go.mod Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/io
go 1.26.0

29
go-io/local.go Normal file
View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: EUPL-1.2
package io
import "os"
// LocalFS provides simple local filesystem helpers used by the API module.
var Local localFS
type localFS struct{}
// EnsureDir creates the directory path if it does not already exist.
func (localFS) EnsureDir(path string) error {
if path == "" || path == "." {
return nil
}
return os.MkdirAll(path, 0o755)
}
// Delete removes the named file, ignoring missing files.
func (localFS) Delete(path string) error {
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

14
go-log/error.go Normal file
View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: EUPL-1.2
package log
import "fmt"
// E wraps an operation label and message in a conventional error.
// If err is non-nil, it is wrapped with %w.
func E(op, message string, err error) error {
if err != nil {
return fmt.Errorf("%s: %s: %w", op, message, err)
}
return fmt.Errorf("%s: %s", op, message)
}

3
go-log/go.mod Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/log
go 1.26.0

4
go.mod
View file

@ -132,6 +132,6 @@ require (
replace (
dappco.re/go/core => ../go
dappco.re/go/core/i18n => ../go-i18n
dappco.re/go/core/io => ../go-io
dappco.re/go/core/log => ../go-log
dappco.re/go/core/io => ./go-io
dappco.re/go/core/log => ./go-log
)

View file

@ -5,6 +5,7 @@ package api
import (
"encoding/json"
"strings"
"unicode"
)
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
@ -66,6 +67,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
"summary": "Health check",
"description": "Returns server health status",
"tags": []string{"system"},
"operationId": operationID("get", "/health"),
"responses": map[string]any{
"200": map[string]any{
"description": "Server is healthy",
@ -93,6 +95,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
"summary": rd.Summary,
"description": rd.Description,
"tags": rd.Tags,
"operationId": operationID(method, fullPath),
"responses": map[string]any{
"200": map[string]any{
"description": "Successful response",
@ -182,3 +185,50 @@ func envelopeSchema(dataSchema map[string]any) map[string]any {
"required": []string{"success"},
}
}
// operationID builds a stable OpenAPI operationId from the HTTP method and path.
// The generated identifier is lower snake_case and strips path parameter braces
// so it stays friendly for downstream SDK generators.
func operationID(method, path string) string {
var b strings.Builder
b.Grow(len(method) + len(path) + 1)
lastUnderscore := false
writeUnderscore := func() {
if b.Len() > 0 && !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
appendToken := func(r rune) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
if unicode.IsUpper(r) {
r = unicode.ToLower(r)
}
b.WriteRune(r)
lastUnderscore = false
return
}
writeUnderscore()
}
for _, r := range method {
appendToken(r)
}
writeUnderscore()
for _, r := range path {
switch r {
case '/', '-', '.', '{', '}', ' ':
writeUnderscore()
default:
appendToken(r)
}
}
out := strings.Trim(b.String(), "_")
if out == "" {
return "operation"
}
return out
}

View file

@ -145,6 +145,9 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
if getOp.(map[string]any)["summary"] != "List items" {
t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"])
}
if getOp.(map[string]any)["operationId"] != "get_api_items_list" {
t.Fatalf("expected operationId='get_api_items_list', got %v", getOp.(map[string]any)["operationId"])
}
// Verify POST /api/items/create exists with request body.
createPath, ok := paths["/api/items/create"]
@ -158,6 +161,9 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
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)["operationId"] != "post_api_items_create" {
t.Fatalf("expected operationId='post_api_items_create', got %v", postOp.(map[string]any)["operationId"])
}
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
@ -206,6 +212,9 @@ func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
content := resp200["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
if getOp["operationId"] != "get_data_fetch" {
t.Fatalf("expected operationId='get_data_fetch', got %v", getOp["operationId"])
}
// Verify envelope structure.
if schema["type"] != "object" {
@ -282,6 +291,10 @@ func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
health := paths["/health"].(map[string]any)["get"].(map[string]any)
if health["operationId"] != "get_health" {
t.Fatalf("expected operationId='get_health', got %v", health["operationId"])
}
}
func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
@ -350,6 +363,9 @@ func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
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"])
}
if postOp.(map[string]any)["operationId"] != "post_tools_file_read" {
t.Fatalf("expected operationId='post_tools_file_read', got %v", postOp.(map[string]any)["operationId"])
}
// Verify POST /tools/metrics_query exists.
metricsPath, ok := paths["/tools/metrics_query"]
@ -363,6 +379,9 @@ func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
if metricsOp.(map[string]any)["summary"] != "Query metrics data" {
t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"])
}
if metricsOp.(map[string]any)["operationId"] != "post_tools_metrics_query" {
t.Fatalf("expected operationId='post_tools_metrics_query', got %v", metricsOp.(map[string]any)["operationId"])
}
// Verify request body is present on both (both are POST with InputSchema).
if postOp.(map[string]any)["requestBody"] == nil {