diff --git a/go-io/go.mod b/go-io/go.mod new file mode 100644 index 0000000..af101a6 --- /dev/null +++ b/go-io/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/core/io + +go 1.26.0 diff --git a/go-io/local.go b/go-io/local.go new file mode 100644 index 0000000..5bfb15d --- /dev/null +++ b/go-io/local.go @@ -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 +} diff --git a/go-log/error.go b/go-log/error.go new file mode 100644 index 0000000..939c8bf --- /dev/null +++ b/go-log/error.go @@ -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) +} diff --git a/go-log/go.mod b/go-log/go.mod new file mode 100644 index 0000000..c513da7 --- /dev/null +++ b/go-log/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/core/log + +go 1.26.0 diff --git a/go.mod b/go.mod index 50f8b1d..b1c4f6b 100644 --- a/go.mod +++ b/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 ) diff --git a/openapi.go b/openapi.go index b98d8d1..40a929e 100644 --- a/openapi.go +++ b/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 +} diff --git a/openapi_test.go b/openapi_test.go index ed4a9b6..058e455 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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 {