From d4a1befd6299ca1327ff9bd229768ae373cf957a Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Tue, 21 Oct 2025 22:44:03 -0700 Subject: [PATCH] Add tools plugin --- tools/README.md | 109 +++++ tools/codec.go | 15 + tools/collect.go | 412 ++++++++++++++++++ tools/doc.go | 5 + tools/dsl/tools.go | 146 +++++++ tools/examples/simple/README.md | 17 + tools/examples/simple/design/design.go | 51 +++ tools/examples/simple/gen/inventory/client.go | 21 + .../simple/gen/inventory/endpoints.go | 25 ++ .../examples/simple/gen/inventory/service.go | 28 ++ tools/examples/simple/gen/tools/codecs.go | 229 ++++++++++ tools/examples/simple/gen/tools/registry.go | 87 ++++ tools/examples/simple/gen/tools/schemas.go | 23 + tools/examples/simple/gen/tools/types.go | 33 ++ tools/examples/simple/go.mod | 26 ++ tools/examples/simple/go.sum | 42 ++ tools/expr/expr.go | 101 +++++ tools/generator.go | 26 ++ tools/render.go | 178 ++++++++ tools/templates.go | 19 + tools/templates/codecs.go.tpl | 119 +++++ tools/templates/registry.go.tpl | 75 ++++ tools/templates/schemas.go.tpl | 5 + tools/templates/types.go.tpl | 9 + tools/tools_test.go | 172 ++++++++ tools/toolspec.go | 30 ++ 26 files changed, 2003 insertions(+) create mode 100644 tools/README.md create mode 100644 tools/codec.go create mode 100644 tools/collect.go create mode 100644 tools/doc.go create mode 100644 tools/dsl/tools.go create mode 100644 tools/examples/simple/README.md create mode 100644 tools/examples/simple/design/design.go create mode 100644 tools/examples/simple/gen/inventory/client.go create mode 100644 tools/examples/simple/gen/inventory/endpoints.go create mode 100644 tools/examples/simple/gen/inventory/service.go create mode 100644 tools/examples/simple/gen/tools/codecs.go create mode 100644 tools/examples/simple/gen/tools/registry.go create mode 100644 tools/examples/simple/gen/tools/schemas.go create mode 100644 tools/examples/simple/gen/tools/types.go create mode 100644 tools/examples/simple/go.mod create mode 100644 tools/examples/simple/go.sum create mode 100644 tools/expr/expr.go create mode 100644 tools/generator.go create mode 100644 tools/render.go create mode 100644 tools/templates.go create mode 100644 tools/templates/codecs.go.tpl create mode 100644 tools/templates/registry.go.tpl create mode 100644 tools/templates/schemas.go.tpl create mode 100644 tools/templates/types.go.tpl create mode 100644 tools/tools_test.go create mode 100644 tools/toolspec.go diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..ebb2d9e8 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,109 @@ +# Goa Tools Plugin + +Many systems built with Goa expose **tools**: structured commands that other +components (LLM workflows, background jobs, admin dashboards) invoke. A tool has +a name plus validated payload and result schemas. Without a central definition it +is easy for services to drift—each consumer recreates its own structs, codecs, +and validation rules. + +The Tools plugin keeps those contracts in sync. Add the DSL to your Goa design, +and the plugin generates a single, canonical set of artefacts—Go structs, JSON +Schema, JSON codecs, and a registry—that every consumer can share. + +## Why use it? + +Use the plugin whenever the same tool must flow through multiple subsystems: + +- LLM orchestration or chat loops that call back into services. +- Temporal payload converters, session persistence, or job workers. +- Administrative or diagnostics tooling that inspects past tool invocations. +- Multiple services that would otherwise hand-roll serializers. + +Define the tool once, regenerate, and every consumer speaks the same schema. + +## Generated assets + +Running `goa gen` produces the following files under `gen/tools/`: + +| File | Purpose | +|---------------|---------------------------------------------------------------------------------------------------| +| `types.go` | Go structs for tool payload/result values defined via the DSL (pure tools only). | +| `schemas.go` | Embedded JSON Schema literals for every payload/result. | +| `codecs.go` | Typed marshal/unmarshal helpers with inlined Goa validation, plus name-driven generic codecs. | +| `registry.go` | A catalogue of `tools.ToolSpec` entries (name, service, set, codecs, schemas) with helper queries. | + +Runtime helpers (`tools/codec.go`, `tools/toolspec.go`) live in this module, so +generated code simply imports `goa.design/plugins/v3/tools` for codecs or the +registry types. + +## DSL overview + +```go +import . "goa.design/plugins/v3/tools/dsl" + +Service("inventory", func() { + ToolSet("ops", func() { + Tool("lookup_item", func() { + Payload(func() { + Attribute("sku", String, "Inventory SKU", func() { MinLength(1) }) + Required("sku") + }) + Result(func() { + Attribute("found", Boolean, "True when item exists") + Attribute("description", String, "Optional description") + Required("found") + }) + }) + + ToolFromMethod("ListRecentItems") // reuse existing Goa method + }) +}) +``` + +- `ToolSet(name, func())` scopes tools to a Goa service. +- `Tool(name, func())` defines a standalone tool (the DSL mirrors Goa `Method`). +- `ToolFromMethod(method, optionalName)` reuses an existing service method so +you only maintain one payload/result definition. + +Pure tool bodies execute during DSL evaluation so the generator sees the final +payload/result attributes. Method-derived tools are tagged so the collector can +reuse the service-generated types, including custom `struct:pkg:path` locations. + +## Consuming the generated artefacts + +```go +import tooldefs "github.com/example/project/gen/tools" + +codec, ok := tooldefs.PayloadCodec("lookup_item") +if !ok { + return fmt.Errorf("unknown tool") +} +raw, err := codec.ToJSON(&tooldefs.LookupItemPayload{Sku: "ABC"}) +``` + +- `PayloadCodec` / `ResultCodec` provide generic JSON codecs backed by the typed helpers. +- Typed helpers (`MarshalLookupItemPayload`, `UnmarshalLookupItemPayload`, `ValidateLookupItemPayload`, …) + include the Goa-generated validation logic. +- `ToolRegistry`, `Names`, `Spec`, `PayloadSchema`, `ResultSchema` expose + metadata for admin or debugging views. + +## Development & tests + +- `go test ./tools/...` exercises the generator and DSL integration. +- `tools/examples/simple` demonstrates a minimal design; run + `goa gen github.com/example/tools-simple/design` in that directory to inspect + the output. + +## FAQ + +**What is a tool?** A named command with a validated payload/result that other +components can call (e.g. `lookup_item`). + +**Why not rely on Goa methods alone?** Goa methods generate transport handlers +(HTTP/gRPC). Tools are often transport-agnostic—invoked by LLMs or background +workers—and may not have direct endpoints. The plugin lets you model those +contracts without adding extra handlers. + +**Do I always get new structs?** Pure tools (defined via `Tool`) generate new +structs. Tools created with `ToolFromMethod` reuse the service’s existing +payload/result types, including custom `struct:pkg:path` locations. diff --git a/tools/codec.go b/tools/codec.go new file mode 100644 index 00000000..ad0c6e1e --- /dev/null +++ b/tools/codec.go @@ -0,0 +1,15 @@ +package tools + +type ( + // JSONCodec is a generic interface for marshaling and unmarshaling JSON values + // for tool payloads and results. Generated code in service toolsets uses JSONCodec + // to encode Go structs into canonical JSON (for sending tool payloads to LLMs or APIs) + // and decode JSON responses into the appropriate Go struct types. For each tool, + // generated code provides strongly-typed JSONCodec values (e.g., JSONCodec[*MyPayload]) + // to ensure compile-time type safety when serializing and deserializing tool inputs + // and outputs. + JSONCodec[T any] struct { + ToJSON func(v T) ([]byte, error) + FromJSON func(data []byte) (T, error) + } +) diff --git a/tools/collect.go b/tools/collect.go new file mode 100644 index 00000000..302a9cc2 --- /dev/null +++ b/tools/collect.go @@ -0,0 +1,412 @@ +package tools + +import ( + "fmt" + "path" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + expr "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + + toolsexpr "goa.design/plugins/v3/tools/expr" +) + +type generator struct { + genpkg string + services *service.ServicesData + typeScope *codegen.NameScope + types map[string]*typeInfo + ordered []*typeInfo + tools []*toolEntry + imports map[string]*codegen.ImportSpec + needsGoa bool + typeImports map[string]*codegen.ImportSpec +} + +type toolEntry struct { + Name string + Service string + Set string + Method *string + Payload *typeInfo + Result *typeInfo +} + +type typeInfo struct { + Key string + TypeName string + Doc string + Def string + SchemaVar string + SchemaLiteral string + ExportedCodec string + GenericCodec string + MarshalFunc string + UnmarshalFunc string + ValidateFunc string + Validation string + HasValidation bool + FullRef string + ElemRef string + Pointer bool + CheckNil bool + MarshalArg string + UnmarshalArg string + ValidationSrc []string + NeedType bool + Import *codegen.ImportSpec + NilError string + DecodeError string + ValidateError string + EmptyError string + Usage typeUsage +} + +type typeUsage string + +const ( + usagePayload typeUsage = "payload" + usageResult typeUsage = "result" +) + +func (u typeUsage) String() string { + return string(u) +} + +func newGenerator(genpkg string) (*generator, error) { + return &generator{ + genpkg: genpkg, + services: service.NewServicesData(expr.Root), + typeScope: codegen.NewNameScope(), + types: make(map[string]*typeInfo), + imports: make(map[string]*codegen.ImportSpec), + typeImports: make(map[string]*codegen.ImportSpec), + }, nil +} + +func (g *generator) collect() error { + if toolsexpr.Root == nil { + return nil + } + for _, ts := range toolsexpr.Root.ToolSets { + var ( + svcData *service.Data + svcName string + ) + if ts.Service != nil { + svcName = ts.Service.Name + svcData = g.services.Get(svcName) + if svcData == nil { + return fmt.Errorf("tools: service %q not found in design", svcName) + } + } + for _, tool := range ts.Tools { + executeToolDSL(tool) + if tool.Method == nil { + return fmt.Errorf("tools: tool %q missing method expression", tool.Name) + } + payload, err := g.typeFor(tool, tool.Method.Payload, usagePayload, svcData) + if err != nil { + return err + } + result, err := g.typeFor(tool, tool.Method.Result, usageResult, svcData) + if err != nil { + return err + } + entry := &toolEntry{ + Name: tool.Name, + Service: svcName, + Set: ts.Name, + Payload: payload, + Result: result, + } + if isDerived(tool) && tool.Method != nil { + method := tool.Method.Name + entry.Method = &method + } + g.tools = append(g.tools, entry) + } + } + sort.Slice(g.tools, func(i, j int) bool { + if g.tools[i].Service == g.tools[j].Service { + return g.tools[i].Name < g.tools[j].Name + } + return g.tools[i].Service < g.tools[j].Service + }) + return nil +} + +func (g *generator) typeFor(tool *toolsexpr.ToolExpr, att *expr.AttributeExpr, usage typeUsage, svcData *service.Data) (*typeInfo, error) { + if att == nil || att.Type == nil || att.Type == expr.Empty { + return nil, nil + } + if isDerived(tool) { + return g.ensureDerivedType(tool, att, usage, svcData) + } + return g.ensurePureType(tool, att, usage) +} + +func (g *generator) ensurePureType(tool *toolsexpr.ToolExpr, att *expr.AttributeExpr, usage typeUsage) (*typeInfo, error) { + base := codegen.Goify(tool.Name, true) + switch usage { + case usagePayload: + base += "Payload" + case usageResult: + base += "Result" + } + typeName := g.typeScope.Unique(base, "") + key := "*" + typeName + if existing := g.types[key]; existing != nil { + return existing, nil + } + doc := fmt.Sprintf("%s defines the JSON %s for the %s tool.", typeName, usage.String(), tool.Name) + def := fmt.Sprintf("%s %s", typeName, g.typeScope.GoTypeDef(att, false, true)) + schemaBytes, err := schemaForAttribute(tool.Method, att) + if err != nil { + return nil, err + } + schemaLiteral := formatSchema(schemaBytes) + schemaVar := "" + if schemaLiteral != "" { + schemaVar = lowerCamel(typeName) + "Schema" + } + validation := buildValidation(att, "", g.typeScope) + info := &typeInfo{ + Key: key, + TypeName: typeName, + Doc: doc, + Def: def, + SchemaVar: schemaVar, + SchemaLiteral: schemaLiteral, + ExportedCodec: typeName + "Codec", + GenericCodec: lowerCamel(typeName) + "Codec", + MarshalFunc: "Marshal" + typeName, + UnmarshalFunc: "Unmarshal" + typeName, + ValidateFunc: "Validate" + typeName, + Validation: validation, + HasValidation: validation != "", + FullRef: "*" + typeName, + ElemRef: typeName, + NeedType: true, + NilError: fmt.Sprintf("%s is nil", lowerCamel(typeName)), + DecodeError: fmt.Sprintf("decode %s", lowerCamel(typeName)), + ValidateError: fmt.Sprintf("validate %s", lowerCamel(typeName)), + EmptyError: fmt.Sprintf("%s JSON is empty", lowerCamel(typeName)), + Usage: usage, + } + if info.HasValidation { + g.needsGoa = true + } + g.addTypeImports(att) + finalizeTypeInfo(info) + g.types[key] = info + g.ordered = append(g.ordered, info) + return info, nil +} + +func (g *generator) ensureDerivedType(tool *toolsexpr.ToolExpr, att *expr.AttributeExpr, usage typeUsage, svcData *service.Data) (*typeInfo, error) { + if svcData == nil { + return nil, fmt.Errorf("tools: derived tool %q missing service data", tool.Name) + } + method := svcData.Method(tool.Method.Name) + if method == nil { + return nil, fmt.Errorf("tools: method %q for tool %q not found", tool.Method.Name, tool.Name) + } + var ( + typeName string + typeRef string + loc *codegen.Location + ) + switch usage { + case usagePayload: + typeName = method.Payload + typeRef = method.PayloadRef + loc = method.PayloadLoc + case usageResult: + typeName = method.Result + typeRef = method.ResultRef + loc = method.ResultLoc + default: + typeName = method.Payload + typeRef = method.PayloadRef + loc = method.PayloadLoc + } + if typeName == "" { + typeName = svcData.Scope.GoTypeName(att) + } + pkgName := packageName(loc, svcData) + if typeRef == "" { + typeRef = svcData.Scope.GoFullTypeRef(att, pkgName) + } + if typeRef == "" { + return nil, fmt.Errorf("tools: unable to compute type reference for tool %q %s", tool.Name, usage) + } + key := typeRef + if existing := g.types[key]; existing != nil { + return existing, nil + } + schemaBytes, err := schemaForAttribute(tool.Method, att) + if err != nil { + return nil, err + } + schemaLiteral := formatSchema(schemaBytes) + schemaVar := "" + if schemaLiteral != "" { + schemaVar = lowerCamel(typeName) + "Schema" + } + validation := buildValidation(att, pkgName, svcData.Scope) + info := &typeInfo{ + Key: key, + TypeName: typeName, + SchemaVar: schemaVar, + SchemaLiteral: schemaLiteral, + ExportedCodec: typeName + "Codec", + GenericCodec: lowerCamel(typeName) + "Codec", + MarshalFunc: "Marshal" + typeName, + UnmarshalFunc: "Unmarshal" + typeName, + ValidateFunc: "Validate" + typeName, + Validation: validation, + HasValidation: validation != "", + FullRef: typeRef, + ElemRef: strings.TrimPrefix(typeRef, "*"), + NilError: fmt.Sprintf("%s is nil", lowerCamel(typeName)), + DecodeError: fmt.Sprintf("decode %s", lowerCamel(typeName)), + ValidateError: fmt.Sprintf("validate %s", lowerCamel(typeName)), + EmptyError: fmt.Sprintf("%s JSON is empty", lowerCamel(typeName)), + Usage: usage, + } + if info.HasValidation { + g.needsGoa = true + } + finalizeTypeInfo(info) + if importPath := importPath(loc, svcData, g.genpkg); importPath != "" { + spec := &codegen.ImportSpec{Path: importPath} + info.Import = spec + g.imports[importPath] = spec + } + g.types[key] = info + g.ordered = append(g.ordered, info) + return info, nil +} + +func packageName(loc *codegen.Location, svc *service.Data) string { + if loc != nil { + return loc.PackageName() + } + if svc != nil { + return svc.PkgName + } + return "" +} + +func importPath(loc *codegen.Location, svc *service.Data, genpkg string) string { + if loc != nil { + if loc.RelImportPath != "" { + return path.Join(genpkg, loc.RelImportPath) + } + return "" + } + if svc == nil { + return "" + } + return path.Join(genpkg, svc.PathName) +} + +func (g *generator) addTypeImports(att *expr.AttributeExpr) { + for _, im := range gatherAttributeImports(g.genpkg, att) { + if im == nil || im.Path == "" { + continue + } + g.typeImports[im.Path] = im + } +} + +func gatherAttributeImports(genpkg string, att *expr.AttributeExpr) []*codegen.ImportSpec { + if att == nil { + return nil + } + var specs []*codegen.ImportSpec + switch dt := att.Type.(type) { + case expr.UserType: + if loc := codegen.UserTypeLocation(dt); loc != nil && loc.RelImportPath != "" { + specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, loc.RelImportPath)}) + } + specs = append(specs, gatherAttributeImports(genpkg, dt.Attribute())...) + case *expr.Array: + specs = append(specs, gatherAttributeImports(genpkg, dt.ElemType)...) + case *expr.Map: + specs = append(specs, gatherAttributeImports(genpkg, dt.KeyType)...) + specs = append(specs, gatherAttributeImports(genpkg, dt.ElemType)...) + case expr.CompositeExpr: + specs = append(specs, gatherAttributeImports(genpkg, dt.Attribute())...) + } + specs = append(specs, codegen.GetMetaTypeImports(att)...) + return specs +} + +func schemaForAttribute(method *expr.MethodExpr, att *expr.AttributeExpr) ([]byte, error) { + if method == nil || att == nil || att.Type == nil || att.Type == expr.Empty { + return nil, nil + } + prev := openapi.Definitions + openapi.Definitions = make(map[string]*openapi.Schema) + defer func() { openapi.Definitions = prev }() + schema := openapi.AttributeTypeSchema(expr.Root.API, att) + if schema == nil { + return nil, nil + } + if len(openapi.Definitions) > 0 { + schema.Definitions = openapi.Definitions + } + return schema.JSON() +} + +func buildValidation(att *expr.AttributeExpr, pkg string, scope *codegen.NameScope) string { + if att == nil || att.Type == nil || att.Type == expr.Empty { + return "" + } + ctx := codegen.NewAttributeContext(false, false, true, pkg, scope) + return strings.TrimSpace(codegen.ValidationCode(att, nil, ctx, true, expr.IsAlias(att.Type), false, "body")) +} + +func formatSchema(schema []byte) string { + if len(schema) == 0 { + return "" + } + content := string(schema) + return "[]byte(`\n" + content + "\n`)" +} + +func lowerCamel(s string) string { + return codegen.Goify(s, false) +} + +func finalizeTypeInfo(info *typeInfo) { + info.Pointer = strings.HasPrefix(info.FullRef, "*") + if info.Pointer { + info.CheckNil = true + info.MarshalArg = "v" + info.UnmarshalArg = "&v" + } else { + info.MarshalArg = "v" + info.UnmarshalArg = "v" + } + if info.Validation != "" { + info.ValidationSrc = strings.Split(info.Validation, "\n") + } +} + +func isDerived(tool *toolsexpr.ToolExpr) bool { + return tool != nil && tool.Derived +} + +func executeToolDSL(tool *toolsexpr.ToolExpr) { + if tool == nil || tool.DSLFunc == nil { + return + } + tool.DSLFunc() + tool.DSLFunc = nil +} diff --git a/tools/doc.go b/tools/doc.go new file mode 100644 index 00000000..ed5a95d7 --- /dev/null +++ b/tools/doc.go @@ -0,0 +1,5 @@ +// Package tools implements the Goa plugin that generates canonical tool +// specifications. It inspects the design DSL to produce a registry of tool +// payloads and results, complete with JSON codecs and JSON Schema blobs that +// downstream services can reuse for serialization, validation, and metadata. +package tools diff --git a/tools/dsl/tools.go b/tools/dsl/tools.go new file mode 100644 index 00000000..71dc3d01 --- /dev/null +++ b/tools/dsl/tools.go @@ -0,0 +1,146 @@ +package dsl + +import ( + "goa.design/goa/v3/eval" + goaexpr "goa.design/goa/v3/expr" + "goa.design/plugins/v3/tools/expr" + + // Register code generators for the tools plugin when the DSL is imported. + _ "goa.design/plugins/v3/tools" +) + +// ToolSet groups tool definitions under a logical name scoped to the current +// service. +// +// ToolSet must appear in a Service expression. +// +// ToolSet takes two arguments: the name of the tool set and the defining DSL. +// The DSL typically calls Tool or ToolFromMethod to register tools. +// +// Example: +// +// import . "goa.design/plugins/v3/tools/dsl" +// +// var _ = Service("inventory", func() { +// ToolSet("admin", func() { +// // Define a pure tool with its own payload/result types +// Tool("rebuild_index", func() { +// Description("Rebuilds the search index") +// Payload(func() { +// Attribute("force", Boolean) +// Required("force") +// }) +// Result(Empty) +// }) +// +// // Reuse an existing service method as a tool +// ToolFromMethod("delete") +// }) +// }) +func ToolSet(name string, fn func()) { + svc, ok := eval.Current().(*goaexpr.ServiceExpr) + if !ok { + eval.IncompatibleDSL() + return + } + ts := &expr.ToolSetExpr{ + Service: svc, + Name: name, + } + expr.Root.ToolSets = append(expr.Root.ToolSets, ts) + if fn != nil { + _ = eval.Execute(fn, ts) + } +} + +// Tool defines a pure tool that does not map to an existing service method. +// The tool DSL may use standard Goa Method DSL helpers such as Payload, Result, +// and Description. +// +// Tool must appear in a ToolSet expression. +// +// Tool accepts the tool name and an optional defining DSL. +// +// Example: +// +// Tool("rotate_keys", func() { +// Description("Rotates API keys for downstream systems") +// Payload(func() { +// Attribute("dry_run", Boolean) +// }) +// Result(Empty) +// }) +func Tool(name string, fn ...func()) { + ts, ok := eval.Current().(*expr.ToolSetExpr) + if !ok { + eval.IncompatibleDSL() + return + } + var dsl func() + if len(fn) > 0 && fn[len(fn)-1] != nil { + dsl = fn[len(fn)-1] + } + method := &goaexpr.MethodExpr{ + Name: name, + Service: ts.Service, + } + tool := &expr.ToolExpr{ + Name: name, + ToolSet: ts, + Method: method, + DSLFunc: func() { + eval.Execute(dsl, method) + }, + } + ts.Tools = append(ts.Tools, tool) + if tool.DSLFunc != nil { + tool.DSLFunc() + tool.DSLFunc = nil + } +} + +// ToolFromMethod registers an existing service method as a tool within the +// current tool set. The tool's payload and result types are automatically +// inferred from the referenced method expression. +// +// ToolFromMethod must appear in a ToolSet expression. +// +// ToolFromMethod accepts one or two arguments. The first argument is the +// unqualified name of a method. An optional second argument may be provided to +// specify a different tool name; if omitted, the method name is used as the +// tool name by default. +// +// Example: +// +// var _ = Service("inventory", func() { +// Method("delete", func() { +// Payload(func() { Attribute("id", String) ; Required("id") }) +// Result(Empty) +// }) +// +// ToolSet("ops", func() { +// ToolFromMethod("delete", "delete_item") +// }) +// }) +func ToolFromMethod(method string, toolName ...string) { + ts, ok := eval.Current().(*expr.ToolSetExpr) + if !ok { + eval.IncompatibleDSL() + return + } + m := ts.Service.Method(method) + if m == nil { + eval.ReportError("tool references unknown method %s::%s", ts.Service.Name, method) + return + } + tool := method + if len(toolName) > 0 { + tool = toolName[0] + } + ts.Tools = append(ts.Tools, &expr.ToolExpr{ + Name: tool, + ToolSet: ts, + Method: m, + Derived: true, + }) +} diff --git a/tools/examples/simple/README.md b/tools/examples/simple/README.md new file mode 100644 index 00000000..e706bbaa --- /dev/null +++ b/tools/examples/simple/README.md @@ -0,0 +1,17 @@ +# Simple Tools Plugin Example + +This example demonstrates how to declare a tool using the Goa tools plugin and +inspect the generated registry. + +## Generate Code + +```bash +go run goa.design/goa/v3/cmd/goa gen github.com/example/tools-simple/design +``` + +After running `goa gen` the plugin writes files under `gen/tools/`: + +- `spec.go` defines the shared `ToolSpec` / `TypeSpec` structs and the + `ToolRegistry` slice describing each defined tool. +- `codecs.go` contains the generated JSON codecs and JSON Schema blobs for every + tool payload and result type. diff --git a/tools/examples/simple/design/design.go b/tools/examples/simple/design/design.go new file mode 100644 index 00000000..614fcdcb --- /dev/null +++ b/tools/examples/simple/design/design.go @@ -0,0 +1,51 @@ +package design + +import ( + . "goa.design/goa/v3/dsl" + . "goa.design/plugins/v3/tools/dsl" +) + +var _ = API("tool_example", func() { + Title("Tool Example API") + Description("Demonstrates the tools plugin with a single tool definition") +}) + +var _ = Service("inventory", func() { + Description("Inventory service exposing tools for asset lookups") + + ToolSet("inventory_tools", func() { + Tool("lookup_item", func() { + Description("Retrieve an item from inventory") + Payload(func() { + Description("Item lookup parameters") + Attribute("sku", String, "Inventory SKU", func() { + MinLength(1) + }) + Required("sku") + }) + Result(func() { + Description("Lookup result with optional details") + Attribute("found", Boolean, "True when the item exists") + Attribute("description", String, "Optional item description") + Required("found") + }) + }) + + Tool("list_recent_items", func() { + Description("List recently added inventory items") + Payload(func() { + Description("Filtering configuration for recent items") + Attribute("limit", Int, "Maximum number of items to return", func() { + Minimum(1) + Maximum(100) + }) + Required("limit") + }) + Result(func() { + Description("Recent items response envelope") + Attribute("items", ArrayOf(String), "Item identifiers returned") + Required("items") + }) + }) + }) +}) diff --git a/tools/examples/simple/gen/inventory/client.go b/tools/examples/simple/gen/inventory/client.go new file mode 100644 index 00000000..a71f3861 --- /dev/null +++ b/tools/examples/simple/gen/inventory/client.go @@ -0,0 +1,21 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// inventory client +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package inventory + +import ( + goa "goa.design/goa/v3/pkg" +) + +// Client is the "inventory" service client. +type Client struct { +} + +// NewClient initializes a "inventory" service client given the endpoints. +func NewClient(goa.Endpoint) *Client { + return &Client{} +} diff --git a/tools/examples/simple/gen/inventory/endpoints.go b/tools/examples/simple/gen/inventory/endpoints.go new file mode 100644 index 00000000..4fb3bd49 --- /dev/null +++ b/tools/examples/simple/gen/inventory/endpoints.go @@ -0,0 +1,25 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// inventory endpoints +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package inventory + +import ( + goa "goa.design/goa/v3/pkg" +) + +// Endpoints wraps the "inventory" service endpoints. +type Endpoints struct { +} + +// NewEndpoints wraps the methods of the "inventory" service with endpoints. +func NewEndpoints(s Service) *Endpoints { + return &Endpoints{} +} + +// Use applies the given middleware to all the "inventory" service endpoints. +func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { +} diff --git a/tools/examples/simple/gen/inventory/service.go b/tools/examples/simple/gen/inventory/service.go new file mode 100644 index 00000000..9234635a --- /dev/null +++ b/tools/examples/simple/gen/inventory/service.go @@ -0,0 +1,28 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// inventory service +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package inventory + +// Inventory service exposing tools for asset lookups +type Service interface { +} + +// APIName is the name of the API as defined in the design. +const APIName = "tool_example" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "inventory" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [0]string{} diff --git a/tools/examples/simple/gen/tools/codecs.go b/tools/examples/simple/gen/tools/codecs.go new file mode 100644 index 00000000..1d059cbb --- /dev/null +++ b/tools/examples/simple/gen/tools/codecs.go @@ -0,0 +1,229 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// Tool Codecs +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package tools + +import ( + "encoding/json" + "fmt" + "unicode/utf8" + + goa "goa.design/goa/v3/pkg" + "goa.design/plugins/v3/tools" +) + +var ( + // LookupItemPayloadCodec serializes values of type *LookupItemPayload to canonical JSON. + LookupItemPayloadCodec = tools.JSONCodec[*LookupItemPayload]{ + ToJSON: MarshalLookupItemPayload, + FromJSON: UnmarshalLookupItemPayload, + } + // LookupItemResultCodec serializes values of type *LookupItemResult to canonical JSON. + LookupItemResultCodec = tools.JSONCodec[*LookupItemResult]{ + ToJSON: MarshalLookupItemResult, + FromJSON: UnmarshalLookupItemResult, + } + // ListRecentItemsPayloadCodec serializes values of type *ListRecentItemsPayload to canonical JSON. + ListRecentItemsPayloadCodec = tools.JSONCodec[*ListRecentItemsPayload]{ + ToJSON: MarshalListRecentItemsPayload, + FromJSON: UnmarshalListRecentItemsPayload, + } + // ListRecentItemsResultCodec serializes values of type *ListRecentItemsResult to canonical JSON. + ListRecentItemsResultCodec = tools.JSONCodec[*ListRecentItemsResult]{ + ToJSON: MarshalListRecentItemsResult, + FromJSON: UnmarshalListRecentItemsResult, + } + // lookupItemPayloadCodec provides an untyped codec for *LookupItemPayload. + lookupItemPayloadCodec = tools.JSONCodec[any]{ + ToJSON: func(v any) ([]byte, error) { + typed, ok := v.(*LookupItemPayload) + if !ok { + return nil, fmt.Errorf("expected *LookupItemPayload, got %T", v) + } + return MarshalLookupItemPayload(typed) + }, + FromJSON: func(data []byte) (any, error) { + return UnmarshalLookupItemPayload(data) + }, + } + // lookupItemResultCodec provides an untyped codec for *LookupItemResult. + lookupItemResultCodec = tools.JSONCodec[any]{ + ToJSON: func(v any) ([]byte, error) { + typed, ok := v.(*LookupItemResult) + if !ok { + return nil, fmt.Errorf("expected *LookupItemResult, got %T", v) + } + return MarshalLookupItemResult(typed) + }, + FromJSON: func(data []byte) (any, error) { + return UnmarshalLookupItemResult(data) + }, + } + // listRecentItemsPayloadCodec provides an untyped codec for *ListRecentItemsPayload. + listRecentItemsPayloadCodec = tools.JSONCodec[any]{ + ToJSON: func(v any) ([]byte, error) { + typed, ok := v.(*ListRecentItemsPayload) + if !ok { + return nil, fmt.Errorf("expected *ListRecentItemsPayload, got %T", v) + } + return MarshalListRecentItemsPayload(typed) + }, + FromJSON: func(data []byte) (any, error) { + return UnmarshalListRecentItemsPayload(data) + }, + } + // listRecentItemsResultCodec provides an untyped codec for *ListRecentItemsResult. + listRecentItemsResultCodec = tools.JSONCodec[any]{ + ToJSON: func(v any) ([]byte, error) { + typed, ok := v.(*ListRecentItemsResult) + if !ok { + return nil, fmt.Errorf("expected *ListRecentItemsResult, got %T", v) + } + return MarshalListRecentItemsResult(typed) + }, + FromJSON: func(data []byte) (any, error) { + return UnmarshalListRecentItemsResult(data) + }, + } +) + +func PayloadCodec(name string) (*tools.JSONCodec[any], bool) { + switch name { + case "list_recent_items": + return &listRecentItemsPayloadCodec, true + case "lookup_item": + return &lookupItemPayloadCodec, true + default: + return nil, false + } +} + +func ResultCodec(name string) (*tools.JSONCodec[any], bool) { + switch name { + case "list_recent_items": + return &listRecentItemsResultCodec, true + case "lookup_item": + return &lookupItemResultCodec, true + default: + return nil, false + } +} +func MarshalLookupItemPayload(v *LookupItemPayload) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("lookupItemPayload is nil") + } + if err := ValidateLookupItemPayload(v); err != nil { + return nil, fmt.Errorf("validate lookupItemPayload: %w", err) + } + return json.Marshal(v) +} + +func UnmarshalLookupItemPayload(data []byte) (*LookupItemPayload, error) { + if len(data) == 0 { + return nil, fmt.Errorf("lookupItemPayload JSON is empty") + } + var v LookupItemPayload + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("decode lookupItemPayload: %w", err) + } + if err := ValidateLookupItemPayload(&v); err != nil { + return nil, fmt.Errorf("validate lookupItemPayload: %w", err) + } + return &v, nil +} + +func ValidateLookupItemPayload(body *LookupItemPayload) (err error) { + if utf8.RuneCountInString(body.Sku) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.sku", body.Sku, utf8.RuneCountInString(body.Sku), 1, true)) + } + return +} +func MarshalLookupItemResult(v *LookupItemResult) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("lookupItemResult is nil") + } + return json.Marshal(v) +} + +func UnmarshalLookupItemResult(data []byte) (*LookupItemResult, error) { + if len(data) == 0 { + return nil, fmt.Errorf("lookupItemResult JSON is empty") + } + var v LookupItemResult + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("decode lookupItemResult: %w", err) + } + return &v, nil +} + +func ValidateLookupItemResult(body *LookupItemResult) (err error) { + _ = body + return +} +func MarshalListRecentItemsPayload(v *ListRecentItemsPayload) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("listRecentItemsPayload is nil") + } + if err := ValidateListRecentItemsPayload(v); err != nil { + return nil, fmt.Errorf("validate listRecentItemsPayload: %w", err) + } + return json.Marshal(v) +} + +func UnmarshalListRecentItemsPayload(data []byte) (*ListRecentItemsPayload, error) { + if len(data) == 0 { + return nil, fmt.Errorf("listRecentItemsPayload JSON is empty") + } + var v ListRecentItemsPayload + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("decode listRecentItemsPayload: %w", err) + } + if err := ValidateListRecentItemsPayload(&v); err != nil { + return nil, fmt.Errorf("validate listRecentItemsPayload: %w", err) + } + return &v, nil +} + +func ValidateListRecentItemsPayload(body *ListRecentItemsPayload) (err error) { + if body.Limit < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.limit", body.Limit, 1, true)) + } + if body.Limit > 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("body.limit", body.Limit, 100, false)) + } + return +} +func MarshalListRecentItemsResult(v *ListRecentItemsResult) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("listRecentItemsResult is nil") + } + if err := ValidateListRecentItemsResult(v); err != nil { + return nil, fmt.Errorf("validate listRecentItemsResult: %w", err) + } + return json.Marshal(v) +} + +func UnmarshalListRecentItemsResult(data []byte) (*ListRecentItemsResult, error) { + if len(data) == 0 { + return nil, fmt.Errorf("listRecentItemsResult JSON is empty") + } + var v ListRecentItemsResult + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("decode listRecentItemsResult: %w", err) + } + if err := ValidateListRecentItemsResult(&v); err != nil { + return nil, fmt.Errorf("validate listRecentItemsResult: %w", err) + } + return &v, nil +} + +func ValidateListRecentItemsResult(body *ListRecentItemsResult) (err error) { + if body.Items == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("items", "body")) + } + return +} diff --git a/tools/examples/simple/gen/tools/registry.go b/tools/examples/simple/gen/tools/registry.go new file mode 100644 index 00000000..2b7d48ea --- /dev/null +++ b/tools/examples/simple/gen/tools/registry.go @@ -0,0 +1,87 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// Tool Registry +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package tools + +import "goa.design/plugins/v3/tools" + +// ToolRegistry enumerates all generated tool specifications. +var ToolRegistry = []tools.ToolSpec{ + { + Name: "list_recent_items", + Service: "inventory", + Set: "inventory_tools", + Payload: tools.TypeSpec{ + Name: "ListRecentItemsPayload", + Schema: listRecentItemsPayloadSchema, + Codec: listRecentItemsPayloadCodec, + }, + Result: tools.TypeSpec{ + Name: "ListRecentItemsResult", + Schema: listRecentItemsResultSchema, + Codec: listRecentItemsResultCodec, + }, + }, + { + Name: "lookup_item", + Service: "inventory", + Set: "inventory_tools", + Payload: tools.TypeSpec{ + Name: "LookupItemPayload", + Schema: lookupItemPayloadSchema, + Codec: lookupItemPayloadCodec, + }, + Result: tools.TypeSpec{ + Name: "LookupItemResult", + Schema: lookupItemResultSchema, + Codec: lookupItemResultCodec, + }, + }, +} + +var toolIndex map[string]*tools.ToolSpec + +func init() { + toolIndex = make(map[string]*tools.ToolSpec, len(ToolRegistry)) + for i := range ToolRegistry { + spec := &ToolRegistry[i] + toolIndex[spec.Name] = spec + } +} + +// Names returns the names of all tools in the registry. +func Names() []string { + names := make([]string, 0, len(toolIndex)) + for name := range toolIndex { + names = append(names, name) + } + return names +} + +// Spec returns the specification for the named tool if it exists. +func Spec(name string) (*tools.ToolSpec, bool) { + spec, ok := toolIndex[name] + return spec, ok +} + +// PayloadSchema returns the JSON schema for the payload of the named tool. +func PayloadSchema(name string) ([]byte, bool) { + spec, ok := toolIndex[name] + if !ok { + return nil, false + } + return spec.Payload.Schema, true +} + +// ResultSchema returns the JSON schema for the result of the named tool. +func ResultSchema(name string) ([]byte, bool) { + spec, ok := toolIndex[name] + if !ok { + return nil, false + } + return spec.Result.Schema, true +} diff --git a/tools/examples/simple/gen/tools/schemas.go b/tools/examples/simple/gen/tools/schemas.go new file mode 100644 index 00000000..2b91f991 --- /dev/null +++ b/tools/examples/simple/gen/tools/schemas.go @@ -0,0 +1,23 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// Tool Schemas +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package tools + +var ( + lookupItemPayloadSchema = []byte(` +{"$schema":"http://json-schema.org/draft-04/hyper-schema","type":"object","properties":{"sku":{"type":"string","description":"Inventory SKU","example":"j6s","minLength":1}},"required":["sku"]} +`) + lookupItemResultSchema = []byte(` +{"$schema":"http://json-schema.org/draft-04/hyper-schema","type":"object","properties":{"description":{"type":"string","description":"Optional item description","example":"Non voluptate fugit porro est qui nesciunt."},"found":{"type":"boolean","description":"True when the item exists","example":false}},"required":["found"]} +`) + listRecentItemsPayloadSchema = []byte(` +{"$schema":"http://json-schema.org/draft-04/hyper-schema","type":"object","properties":{"limit":{"type":"integer","description":"Maximum number of items to return","example":82,"format":"int64","minimum":1,"maximum":100}},"required":["limit"]} +`) + listRecentItemsResultSchema = []byte(` +{"$schema":"http://json-schema.org/draft-04/hyper-schema","type":"object","properties":{"items":{"type":"array","items":{"type":"string","example":"Alias autem aut vel quia."},"description":"Item identifiers returned","example":["Voluptatum minima odio eveniet.","Illo ullam ex."]}},"required":["items"]} +`) +) diff --git a/tools/examples/simple/gen/tools/types.go b/tools/examples/simple/gen/tools/types.go new file mode 100644 index 00000000..db512169 --- /dev/null +++ b/tools/examples/simple/gen/tools/types.go @@ -0,0 +1,33 @@ +// Code generated by goa v3.22.6, DO NOT EDIT. +// +// Tool Types +// +// Command: +// $ goa gen github.com/example/tools-simple/design + +package tools + +type ( + // LookupItemPayload defines the JSON payload for the lookup_item tool. + LookupItemPayload struct { + // Inventory SKU + Sku string + } + // LookupItemResult defines the JSON result for the lookup_item tool. + LookupItemResult struct { + // True when the item exists + Found bool + // Optional item description + Description *string + } + // ListRecentItemsPayload defines the JSON payload for the list_recent_items tool. + ListRecentItemsPayload struct { + // Maximum number of items to return + Limit int + } + // ListRecentItemsResult defines the JSON result for the list_recent_items tool. + ListRecentItemsResult struct { + // Item identifiers returned + Items []string + } +) diff --git a/tools/examples/simple/go.mod b/tools/examples/simple/go.mod new file mode 100644 index 00000000..00198311 --- /dev/null +++ b/tools/examples/simple/go.mod @@ -0,0 +1,26 @@ +module github.com/example/tools-simple + +go 1.24.4 + +require ( + goa.design/goa/v3 v3.22.6 + goa.design/plugins/v3 v3.0.0-00010101000000-000000000000 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace goa.design/plugins/v3 => ../../.. diff --git a/tools/examples/simple/go.sum b/tools/examples/simple/go.sum new file mode 100644 index 00000000..20af8e46 --- /dev/null +++ b/tools/examples/simple/go.sum @@ -0,0 +1,42 @@ +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= +github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b/go.mod h1:Bj8LjjP0ReT1eKt5QlKjwgi5AFm5mI6O1A2G4ChI0Ag= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +goa.design/goa/v3 v3.22.6 h1:D2qDkAvdpf6ePr2iXKT+Ple5WDrjyes3iOfYD2yCpw0= +goa.design/goa/v3 v3.22.6/go.mod h1:rhssEXxox3+sKnYp18hPNFCz65I4hLWHEtJKewoNJWk= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/expr/expr.go b/tools/expr/expr.go new file mode 100644 index 00000000..f84dbd4a --- /dev/null +++ b/tools/expr/expr.go @@ -0,0 +1,101 @@ +package expr + +import ( + "fmt" + + "goa.design/goa/v3/eval" + goaexpr "goa.design/goa/v3/expr" +) + +type ( + // RootExpr aggregates tool sets grouped by service. + RootExpr struct { + ToolSets []*ToolSetExpr + } + + // ToolSetExpr groups tools under a common name (per-service). + ToolSetExpr struct { + Name string + Service *goaexpr.ServiceExpr + Tools []*ToolExpr + } + + // ToolExpr represents an individual tool definition. + ToolExpr struct { + Name string + ToolSet *ToolSetExpr + Method *goaexpr.MethodExpr + DSLFunc func() + Derived bool + } +) + +// Root is the design root expression. +var Root = &RootExpr{} + +func init() { + if err := eval.Register(Root); err != nil { + panic(err) + } +} + +// EvalName implements eval.Expression. +func (r *RootExpr) EvalName() string { + return "tools plugin" +} + +// WalkSets satisfies eval.Root. +func (r *RootExpr) WalkSets(walk eval.SetWalker) { + if len(r.ToolSets) == 0 { + return + } + walk(eval.ToExpressionSet(r.ToolSets)) +} + +// DependsOn ensures the Goa core DSL executes first. +func (r *RootExpr) DependsOn() []eval.Root { + return []eval.Root{goaexpr.Root} +} + +// Packages returns the DSL import path for better error locations. +func (r *RootExpr) Packages() []string { + return []string{"goa.design/plugins/v3/tools/dsl"} +} + +// Reset clears accumulated tool sets between DSL runs. +func (r *RootExpr) Reset() { + r.ToolSets = nil +} + +// / EvalName implements eval.Expression. +func (t *ToolSetExpr) EvalName() string { + if t.Service == nil { + return fmt.Sprintf("toolset %s", t.Name) + } + return fmt.Sprintf("toolset %s::%s", t.Service.Name, t.Name) +} + +// EvalName implements eval.Expression. +func (t *ToolExpr) EvalName() string { + if t.ToolSet == nil { + return fmt.Sprintf("tool %s", t.Name) + } + return fmt.Sprintf("tool %s::%s", t.ToolSet.EvalName(), t.Name) +} + +// Finalize ensures the payload and result types referenced by tools are marked +// with the force-generate meta so they are emitted by downstream generators +// even if they are not explicitly referenced by a service method. +func (t *ToolSetExpr) Finalize() { + for _, tool := range t.Tools { + if tool == nil || tool.Method == nil { + continue + } + if tool.Method.Payload != nil { + tool.Method.Payload.AddMeta("type:generate:force", "true") + } + if tool.Method.Result != nil { + tool.Method.Result.AddMeta("type:generate:force", "true") + } + } +} diff --git a/tools/generator.go b/tools/generator.go new file mode 100644 index 00000000..d8d09f9d --- /dev/null +++ b/tools/generator.go @@ -0,0 +1,26 @@ +package tools + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +func init() { + codegen.RegisterPlugin("tools", "gen", nil, Generate) +} + +// Generate emits the tool metadata, schemas, and codec files under gen/tools. +func Generate(genpkg string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + g, err := newGenerator(genpkg) + if err != nil { + return nil, err + } + if err := g.collect(); err != nil { + return nil, err + } + out := g.render() + if len(out) == 0 { + return files, nil + } + return append(files, out...), nil +} diff --git a/tools/render.go b/tools/render.go new file mode 100644 index 00000000..e4c8d829 --- /dev/null +++ b/tools/render.go @@ -0,0 +1,178 @@ +package tools + +import ( + "path/filepath" + "sort" + "strings" + + "goa.design/goa/v3/codegen" +) + +type ( + typesTemplateData struct { + Types []*typeInfo + } + schemasTemplateData struct { + Types []*typeInfo + } + codecsTemplateData struct { + Types []*typeInfo + Tools []*toolEntry + } + registryTemplateData struct { + Tools []*toolEntry + } +) + +func (g *generator) render() []*codegen.File { + if len(g.tools) == 0 { + return nil + } + var files []*codegen.File + if f := g.renderTypes(); f != nil { + files = append(files, f) + } + if f := g.renderSchemas(); f != nil { + files = append(files, f) + } + if f := g.renderCodecs(); f != nil { + files = append(files, f) + } + if f := g.renderRegistry(); f != nil { + files = append(files, f) + } + return files +} + +func (g *generator) renderTypes() *codegen.File { + var pure []*typeInfo + for _, info := range g.ordered { + if info.NeedType { + pure = append(pure, info) + } + } + if len(pure) == 0 { + return nil + } + header := codegen.Header("Tool Types", "tools", g.typeImportList()) + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "tools", "types.go"), + SectionTemplates: []*codegen.SectionTemplate{ + header, + { + Name: "tools-types", + Source: toolsTemplates.Read(typesT), + Data: &typesTemplateData{Types: pure}, + }, + }, + } +} + +func (g *generator) renderSchemas() *codegen.File { + var schemas []*typeInfo + for _, info := range g.ordered { + if info.SchemaLiteral != "" { + schemas = append(schemas, info) + } + } + if len(schemas) == 0 { + return nil + } + header := codegen.Header("Tool Schemas", "tools", nil) + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "tools", "schemas.go"), + SectionTemplates: []*codegen.SectionTemplate{ + header, + { + Name: "tools-schemas", + Source: toolsTemplates.Read(schemasT), + Data: &schemasTemplateData{Types: schemas}, + }, + }, + } +} + +func (g *generator) renderCodecs() *codegen.File { + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("goa.design/plugins/v3/tools"), + } + if g.needsUnicodeImport() { + imports = append(imports, codegen.SimpleImport("unicode/utf8")) + } + if g.needsGoa { + imports = append(imports, codegen.GoaImport("")) + } + if len(g.imports) > 0 { + paths := make([]string, 0, len(g.imports)) + for p := range g.imports { + paths = append(paths, p) + } + sort.Strings(paths) + for _, p := range paths { + imports = append(imports, g.imports[p]) + } + } + header := codegen.Header("Tool Codecs", "tools", imports) + data := &codecsTemplateData{ + Types: g.ordered, + Tools: g.tools, + } + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "tools", "codecs.go"), + SectionTemplates: []*codegen.SectionTemplate{ + header, + { + Name: "tools-codecs", + Source: toolsTemplates.Read(codecsT), + Data: data, + }, + }, + } +} + +func (g *generator) renderRegistry() *codegen.File { + if len(g.tools) == 0 { + return nil + } + header := codegen.Header("Tool Registry", "tools", []*codegen.ImportSpec{ + codegen.SimpleImport("goa.design/plugins/v3/tools"), + }) + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "tools", "registry.go"), + SectionTemplates: []*codegen.SectionTemplate{ + header, + { + Name: "tools-registry", + Source: toolsTemplates.Read(registryT), + Data: ®istryTemplateData{Tools: g.tools}, + }, + }, + } +} + +func (g *generator) typeImportList() []*codegen.ImportSpec { + if len(g.typeImports) == 0 { + return nil + } + paths := make([]string, 0, len(g.typeImports)) + for p := range g.typeImports { + paths = append(paths, p) + } + sort.Strings(paths) + imports := make([]*codegen.ImportSpec, 0, len(paths)) + for _, p := range paths { + imports = append(imports, g.typeImports[p]) + } + return imports +} + +func (g *generator) needsUnicodeImport() bool { + for _, info := range g.ordered { + if info.HasValidation && strings.Contains(info.Validation, "utf8.") { + return true + } + } + return false +} diff --git a/tools/templates.go b/tools/templates.go new file mode 100644 index 00000000..49f12810 --- /dev/null +++ b/tools/templates.go @@ -0,0 +1,19 @@ +package tools + +import ( + "embed" + + "goa.design/goa/v3/codegen/template" +) + +const ( + typesT = "types" + schemasT = "schemas" + codecsT = "codecs" + registryT = "registry" +) + +//go:embed templates/* +var templateFS embed.FS + +var toolsTemplates = &template.TemplateReader{FS: templateFS} diff --git a/tools/templates/codecs.go.tpl b/tools/templates/codecs.go.tpl new file mode 100644 index 00000000..eba8a8e9 --- /dev/null +++ b/tools/templates/codecs.go.tpl @@ -0,0 +1,119 @@ +var ( +{{- range .Types }} + // {{ .ExportedCodec }} serializes values of type {{ .FullRef }} to canonical JSON. + {{ .ExportedCodec }} = tools.JSONCodec[{{ .FullRef }}]{ + ToJSON: {{ .MarshalFunc }}, + FromJSON: {{ .UnmarshalFunc }}, + } + +{{- end }} +{{- range .Types }} + // {{ .GenericCodec }} provides an untyped codec for {{ .FullRef }}. + {{ .GenericCodec }} = tools.JSONCodec[any]{ + ToJSON: func(v any) ([]byte, error) { + typed, ok := v.({{ .FullRef }}) + if !ok { + return nil, fmt.Errorf("expected {{ .FullRef }}, got %T", v) + } + return {{ .MarshalFunc }}(typed) + }, + FromJSON: func(data []byte) (any, error) { + return {{ .UnmarshalFunc }}(data) + }, + } + +{{- end }} +) + +func PayloadCodec(name string) (*tools.JSONCodec[any], bool) { + switch name { +{{- range .Tools }} + {{- if .Payload }} + case "{{ .Name }}": + return &{{ .Payload.GenericCodec }}, true + {{- end }} +{{- end }} + default: + return nil, false + } +} + +func ResultCodec(name string) (*tools.JSONCodec[any], bool) { + switch name { +{{- range .Tools }} + {{- if .Result }} + case "{{ .Name }}": + return &{{ .Result.GenericCodec }}, true + {{- end }} +{{- end }} + default: + return nil, false + } +} + +{{- range .Types }} +func {{ .MarshalFunc }}(v {{ .FullRef }}) ([]byte, error) { + {{- if .CheckNil }} + if v == nil { + return nil, fmt.Errorf("{{ .NilError }}") + } + {{- end }} + {{- if .HasValidation }} + if err := {{ .ValidateFunc }}({{ .MarshalArg }}); err != nil { + return nil, fmt.Errorf("{{ .ValidateError }}: %w", err) + } + {{- end }} + return json.Marshal(v) +} + +func {{ .UnmarshalFunc }}(data []byte) ({{ .FullRef }}, error) { + {{- if not .Pointer }} + var zero {{ .FullRef }} + {{- end }} + if len(data) == 0 { + {{- if .Pointer }} + return nil, fmt.Errorf("{{ .EmptyError }}") + {{- else }} + return zero, fmt.Errorf("{{ .EmptyError }}") + {{- end }} + } + var v {{ .ElemRef }} + if err := json.Unmarshal(data, &v); err != nil { + {{- if .Pointer }} + return nil, fmt.Errorf("{{ .DecodeError }}: %w", err) + {{- else }} + return zero, fmt.Errorf("{{ .DecodeError }}: %w", err) + {{- end }} + } + {{- if .HasValidation }} + if err := {{ .ValidateFunc }}({{ .UnmarshalArg }}); err != nil { + {{- if .Pointer }} + return nil, fmt.Errorf("{{ .ValidateError }}: %w", err) + {{- else }} + return zero, fmt.Errorf("{{ .ValidateError }}: %w", err) + {{- end }} + } + {{- end }} + {{- if .Pointer }} + return &v, nil + {{- else }} + return v, nil + {{- end }} +} + +func {{ .ValidateFunc }}(body {{ .FullRef }}) (err error) { + {{- if .HasValidation }} + {{- range .ValidationSrc }} + {{- if eq . "" }} + + {{- else }} + {{ . }} + {{- end }} + {{- end }} + {{- else }} + _ = body + {{- end }} + return +} + +{{- end }} diff --git a/tools/templates/registry.go.tpl b/tools/templates/registry.go.tpl new file mode 100644 index 00000000..ebbee607 --- /dev/null +++ b/tools/templates/registry.go.tpl @@ -0,0 +1,75 @@ +// ToolRegistry enumerates all generated tool specifications. +var ToolRegistry = []tools.ToolSpec{ +{{- range .Tools }} + { + Name: "{{ .Name }}", + {{- if .Service }} + Service: "{{ .Service }}", + {{- end }} + {{- if .Set }} + Set: "{{ .Set }}", + {{- end }} + {{- if .Payload }} + Payload: tools.TypeSpec{ + Name: "{{ .Payload.TypeName }}", + {{- if .Payload.SchemaVar }} + Schema: {{ .Payload.SchemaVar }}, + {{- end }} + Codec: {{ .Payload.GenericCodec }}, + }, + {{- end }} + {{- if .Result }} + Result: tools.TypeSpec{ + Name: "{{ .Result.TypeName }}", + {{- if .Result.SchemaVar }} + Schema: {{ .Result.SchemaVar }}, + {{- end }} + Codec: {{ .Result.GenericCodec }}, + }, + {{- end }} + }, +{{- end }} +} + +var toolIndex map[string]*tools.ToolSpec + +func init() { + toolIndex = make(map[string]*tools.ToolSpec, len(ToolRegistry)) + for i := range ToolRegistry { + spec := &ToolRegistry[i] + toolIndex[spec.Name] = spec + } +} + +// Names returns the names of all tools in the registry. +func Names() []string { + names := make([]string, 0, len(toolIndex)) + for name := range toolIndex { + names = append(names, name) + } + return names +} + +// Spec returns the specification for the named tool if it exists. +func Spec(name string) (*tools.ToolSpec, bool) { + spec, ok := toolIndex[name] + return spec, ok +} + +// PayloadSchema returns the JSON schema for the payload of the named tool. +func PayloadSchema(name string) ([]byte, bool) { + spec, ok := toolIndex[name] + if !ok { + return nil, false + } + return spec.Payload.Schema, true +} + +// ResultSchema returns the JSON schema for the result of the named tool. +func ResultSchema(name string) ([]byte, bool) { + spec, ok := toolIndex[name] + if !ok { + return nil, false + } + return spec.Result.Schema, true +} diff --git a/tools/templates/schemas.go.tpl b/tools/templates/schemas.go.tpl new file mode 100644 index 00000000..f65b636f --- /dev/null +++ b/tools/templates/schemas.go.tpl @@ -0,0 +1,5 @@ +var ( +{{- range .Types }} + {{ .SchemaVar }} = {{ .SchemaLiteral }} +{{- end }} +) diff --git a/tools/templates/types.go.tpl b/tools/templates/types.go.tpl new file mode 100644 index 00000000..b4874eff --- /dev/null +++ b/tools/templates/types.go.tpl @@ -0,0 +1,9 @@ +type ( +{{- range $i, $t := .Types }} + {{- if gt $i 0 }} + + {{- end }} + // {{ $t.Doc }} + {{ $t.Def }} +{{- end }} +) diff --git a/tools/tools_test.go b/tools/tools_test.go new file mode 100644 index 00000000..8429b73a --- /dev/null +++ b/tools/tools_test.go @@ -0,0 +1,172 @@ +package tools_test + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "goa.design/goa/v3/codegen" + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + goaexpr "goa.design/goa/v3/expr" + "goa.design/plugins/v3/tools" + toolsdsl "goa.design/plugins/v3/tools/dsl" + toolsexpr "goa.design/plugins/v3/tools/expr" +) + +func buildTestDSL(t *testing.T) *goaexpr.RootExpr { + t.Helper() + toolsexpr.Root.Reset() + return codegen.RunDSL(t, func() { + API("tool-api", func() {}) + + var GetKeyEventsParams = Type("GetKeyEventsParams", func() { + Attribute("org_id", String) + Required("org_id") + }) + + var GetKeyEventsResult = Type("GetKeyEventsResult", func() { + Attribute("status", String) + Required("status") + }) + + var ListDevicesPayload = Type("ListDevicesPayload", func() { + Attribute("org_id", String) + Required("org_id") + }) + + Service("toolsvc", func() { + Method("ListDevices", func() { + Payload(ListDevicesPayload) + Result(String) + }) + + toolsdsl.ToolSet("ada_tools", func() { + toolsdsl.Tool("get_key_events", func() { + Payload(GetKeyEventsParams) + Result(GetKeyEventsResult) + }) + toolsdsl.ToolFromMethod("ListDevices") + }) + }) + }) +} + +func TestToolDSL(t *testing.T) { + t.Cleanup(func() { toolsexpr.Root.Reset() }) + + _ = buildTestDSL(t) + + if !assert.Len(t, toolsexpr.Root.ToolSets, 1) { + return + } + ts := toolsexpr.Root.ToolSets[0] + assert.Equal(t, "toolsvc", ts.Service.Name) + assert.Equal(t, "ada_tools", ts.Name) + if !assert.Len(t, ts.Tools, 2) { + return + } + + pure := ts.Tools[0] + if assert.NotNil(t, pure) { + assert.Equal(t, "get_key_events", pure.Name) + if assert.NotNil(t, pure.Method.Payload) { + assert.Equal(t, "GetKeyEventsParams", pure.Method.Payload.Type.Name()) + } + if assert.NotNil(t, pure.Method.Result) { + assert.Equal(t, "GetKeyEventsResult", pure.Method.Result.Type.Name()) + } + } + + derived := ts.Tools[1] + if assert.NotNil(t, derived) { + assert.Equal(t, "ListDevices", derived.Name) + if assert.NotNil(t, derived.Method.Payload) { + assert.Equal(t, "ListDevicesPayload", derived.Method.Payload.Type.Name()) + } + } +} + +func TestGenerateRegistry(t *testing.T) { + t.Cleanup(func() { toolsexpr.Root.Reset() }) + + root := buildTestDSL(t) + + files, err := tools.Generate("example.com/gen", []eval.Root{root}, nil) + if !assert.NoError(t, err) { + return + } + if !assert.Len(t, files, 4) { + return + } + + typesPath := filepath.Join(codegen.Gendir, "tools", "types.go") + schemasPath := filepath.Join(codegen.Gendir, "tools", "schemas.go") + codecsPath := filepath.Join(codegen.Gendir, "tools", "codecs.go") + registryPath := filepath.Join(codegen.Gendir, "tools", "registry.go") + + filesByPath := map[string]*codegen.File{} + for _, f := range files { + filesByPath[f.Path] = f + } + + typesFile, ok := filesByPath[typesPath] + if !assert.True(t, ok, "types.go not emitted") { + return + } + schemasFile, ok := filesByPath[schemasPath] + if !assert.True(t, ok, "schemas.go not emitted") { + return + } + codecFile, ok := filesByPath[codecsPath] + if !assert.True(t, ok, "codecs.go not emitted") { + return + } + registryFile, ok := filesByPath[registryPath] + if !assert.True(t, ok, "registry.go not emitted") { + return + } + + var typeBuf bytes.Buffer + for _, sec := range typesFile.SectionTemplates { + if !assert.NoError(t, sec.Write(&typeBuf)) { + return + } + } + typesOut := typeBuf.String() + assert.Contains(t, typesOut, "type (") + assert.Contains(t, typesOut, "GetKeyEventsPayload defines the JSON payload") + assert.NotContains(t, typesOut, "ListDevicesPayload defines the JSON payload") + + var schemaBuf bytes.Buffer + for _, sec := range schemasFile.SectionTemplates { + if !assert.NoError(t, sec.Write(&schemaBuf)) { + return + } + } + schemasOut := schemaBuf.String() + assert.Contains(t, schemasOut, "getKeyEventsPayloadSchema") + assert.Contains(t, schemasOut, "$schema") + + var codecBuf bytes.Buffer + for _, sec := range codecFile.SectionTemplates { + if !assert.NoError(t, sec.Write(&codecBuf)) { + return + } + } + codecOut := codecBuf.String() + assert.Contains(t, codecOut, "import (\n\t\"encoding/json\"") + assert.Contains(t, codecOut, "GetKeyEventsPayloadCodec = tools.JSONCodec[*GetKeyEventsPayload]") + assert.Contains(t, codecOut, "func MarshalGetKeyEventsPayload") + + var registryBuf bytes.Buffer + for _, sec := range registryFile.SectionTemplates { + if !assert.NoError(t, sec.Write(®istryBuf)) { + return + } + } + registryOut := registryBuf.String() + assert.Contains(t, registryOut, "var ToolRegistry = []tools.ToolSpec") + assert.Contains(t, registryOut, "Payload: tools.TypeSpec{") +} diff --git a/tools/toolspec.go b/tools/toolspec.go new file mode 100644 index 00000000..31e12d3f --- /dev/null +++ b/tools/toolspec.go @@ -0,0 +1,30 @@ +package tools + +type ( + // ToolSpec captures metadata for a registered tool, including payload + // and result specs. + ToolSpec struct { + // Name of the tool. + Name string + // Name of the tool set that the tool is defined in. + Set string + // Name of the service that the set is defined in. + Service string + // Name of the method that the tool is derived from, if any. + Method *string + // Payload type specification. + Payload TypeSpec + // Result type specification. + Result TypeSpec + } + + // TypeSpec describes the name and schema for a tool payload or result. + TypeSpec struct { + // Name of the type. + Name string + // JSON schema for the type. + Schema []byte + // Codec is a JSON codec for the type. + Codec JSONCodec[any] + } +)