feat(api): add stable openapi operation ids
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
491c9a1c69
commit
16abc45efa
7 changed files with 120 additions and 2 deletions
3
go-io/go.mod
Normal file
3
go-io/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/io
|
||||
|
||||
go 1.26.0
|
||||
29
go-io/local.go
Normal file
29
go-io/local.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package io
|
||||
|
||||
import "os"
|
||||
|
||||
// LocalFS provides simple local filesystem helpers used by the API module.
|
||||
var Local localFS
|
||||
|
||||
type localFS struct{}
|
||||
|
||||
// EnsureDir creates the directory path if it does not already exist.
|
||||
func (localFS) EnsureDir(path string) error {
|
||||
if path == "" || path == "." {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
// Delete removes the named file, ignoring missing files.
|
||||
func (localFS) Delete(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
go-log/error.go
Normal file
14
go-log/error.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package log
|
||||
|
||||
import "fmt"
|
||||
|
||||
// E wraps an operation label and message in a conventional error.
|
||||
// If err is non-nil, it is wrapped with %w.
|
||||
func E(op, message string, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %s: %w", op, message, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %s", op, message)
|
||||
}
|
||||
3
go-log/go.mod
Normal file
3
go-log/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dappco.re/go/core/log
|
||||
|
||||
go 1.26.0
|
||||
4
go.mod
4
go.mod
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
50
openapi.go
50
openapi.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue